Frontend Masters Boost RSS Feed https://frontendmasters.com/blog Helping Your Journey to Senior Developer Fri, 14 Nov 2025 16:43:20 +0000 en-US hourly 1 https://wordpress.org/?v=6.8.3 225069128 Vite+ https://frontendmasters.com/blog/vite/ https://frontendmasters.com/blog/vite/#respond Fri, 14 Nov 2025 16:43:19 +0000 https://frontendmasters.com/blog/?p=7761 Probably worth keeping an eye on Vite+ (still in “early access”). They say it’s “everything you’ve been duct-taping together” which feels actually kinda fair when you consider this has “dev, build, test, lint, format, monorepo caching & more in a single dependency.” So even if you’re using Vite anyway, perhaps you’d get to ditch Jest for Vitest, ESLint for Oxlint, Prettier for Oxfmt, and whatever monorepo cludge you got (possible Turborepo/Nx/Lerna) for however this thing does it. My favorite part is that the actual parser under the hood would be the same across all the parts, which just feels right.

]]>
https://frontendmasters.com/blog/vite/feed/ 0 7761
Browserslist & Baseline https://frontendmasters.com/blog/browserslist-baseline/ https://frontendmasters.com/blog/browserslist-baseline/#respond Thu, 13 Nov 2025 19:56:46 +0000 https://frontendmasters.com/blog/?p=7741 I saw Tony Conway & Jeremy Wagner’s post on web.dev, Use Baseline with Browserslist, and I had a little play with it myself (saved live stream). Allow me to write down what I know and what I learned.

So here’s Browserslist1.

Browserslist is the developer community at it’s best. There are a bunch of tools that make choices about what they do based on what browsers they are trying to support. Like the intro on their homepage says:

Shared browser compatibility config for popular JavaScript tools like Autoprefixer / PostCSS, Babel, ESLint, and Webpack

Links to those tools added by me, linking to their Browserslist details. LightningCSS is another one.

So instead of all these tools coming up with their own syntax for how to express a set of supported browsers, they all use this shared syntax. And not even just a shared syntax, they can even share the same location or file (i.e. .browserslist) to store that information.

That’s great. It’s in the same vein as the famed package.json file being the canonical source of packages and JavaScript processing information, or .editorconfig for all editor behavior and prettification needs.

Browserslist recommends "defaults" if “you’re building a web application for the global audience.” This translates to their config "> 0.5%, last 2 versions, Firefox ESR, not dead". That translates to any browser which has more than half a percent of browser share (it gets browser data like this), the last two major versions of all major browsers, Firefox “Extended Support Release” specifically, and only “not dead” browsers (as in, not Internet Explorer).

That’s pretty reasonable? But you can always add things to your support list if you need to go deeper or be more specific.

But now Google is in the game of qualifiying web platform features with Baseline.

Web Platform Baseline brings clarity to information about browser support for web platform features.

Their biggest rubber stamps are “widely available” and “newly available”.

  • Baseline Widely available includes all web features that were fully supported by the Baseline core browser set 30 or more months in the past.
  • Baseline year feature sets, for example Baseline 2020, include all features that were Newly available at the end of the specified year.

This means you can literally use Browserslist config strings to use these delegations.

"baseline newly available"
"baseline widely available"
"baseline 2022"

Specifying a year, like the last example above, might be how your project wants to roll! I don’t hate it, honestly. If I were to use "baseline 2020" as config and run CSS through Lightning CSS, I’d see some transformations like this:

oklch() not supported at all, so transformed into three different formats, supporting as much as it can. light-dark() not supported at all, so provides custom properties for your own implementation, and mask is vendor prefixed.

If we went back to "baseline 2018" we’d even see some big transformations of padding-inline-start transformed into padding-left for a big ol’ pile of :lang() values and padding-right for another big pile (!!).

If we went forward to "baseline 2022" we’d see the color() declaration go away, leaving only the #hex and lab() values left, but the rest would be the same.

I think the general “recommendation” (if that’s fair) is to use "baseline widely available" where our CSS transformations are like this:

The oklch() color is left alone, but we still see the light-dark() transformation and the vendor prefixing for mask.

Using "baseline newly available" is still somewhat cross-browser compatible, just not to the level of widely available. To be “widely” available the feature needs to be baseline for 30 months! So I’d say pretty-darn available. Using "baseline newly available" we get:

Just some trivial conversion of colors, which is mostly a minification thing.

Example Repo

I tossed together a repo for testing this stuff. You can basically change the config here. It’s an npm run dev thing and it’s wired up to not fingerprint or minify, so it’s easy to see both src and dist files like the screenshots I was doing above.

It’s also wired up to do JavaScript processing with Babel, although I’m not 100% sure I even did that part right, but if you wanted to test JavaScript transformation on this stuff, it might be a good starting point.

Stuff That Isn’t Touched

Remember that CSS features aren’t always transformable to backwards-compatible things. Like if you use @layer or rlh units or something, that’s just going to get left alone despite any browser support levels. Same with stuff like shape(), which is brand new, and would be awesome if they ported it to something, but alas, they do not.

Final Thoughts

I think it comes down to a battle: "defaults" vs. "baseline widely available". Anything else is just playing around or very specialty situations. Between the two, I think I’d actually go with "baseline widely available". It just seems a smidge more modern and I like the momentum behind it.

The best possible move is to use your own analytics data to inform the choices. This can be done with a Baseline Target Report and Jeremy Wagner and Rachel Andrew get into it in How to choose your Baseline target.


  1. The pluralization of Browserslist is weird. Feels like it should be Browserlist. You’ll also see config strings like “last 2 version” which then lacks the pluralization it wants (but it also works pluralized). 🤷‍♀️ ↩︎

]]>
https://frontendmasters.com/blog/browserslist-baseline/feed/ 0 7741
A person at the other end https://frontendmasters.com/blog/a-person-at-the-other-end/ https://frontendmasters.com/blog/a-person-at-the-other-end/#respond Tue, 11 Nov 2025 22:45:11 +0000 https://frontendmasters.com/blog/?p=7745

Code is ephemeral. What works right now might get deleted next month.

That is no judgement on the code, it is a statement that code isn’t the value prop, the product is.

I try and remember this during code review, because even though we call it code review, there is a person at the other end.

Sometimes it makes sense to ask for a refactor, for the sake of the code.

Sometimes it makes sense to approve it and get on with your day, for the sake of the person.

Toby Osbourn

]]>
https://frontendmasters.com/blog/a-person-at-the-other-end/feed/ 0 7745
Perfectly Pointed Tooltips: To The Corners https://frontendmasters.com/blog/perfectly-pointed-tooltips-to-the-corners/ https://frontendmasters.com/blog/perfectly-pointed-tooltips-to-the-corners/#respond Mon, 10 Nov 2025 15:04:33 +0000 https://frontendmasters.com/blog/?p=7714 Ready for the last challenge?

We are still creating tooltips that follow their anchors, and this time we will work with new positions and learn new tricks. I will assume you have read and understood the first two parts, as I will skip the things I already explained there. Fair warning, if you haven’t read those first two you might get a little lost.

Article Series

At the time of writing, only Chrome and Edge have full support of the features we will be using.

As usual, a demo of what we are making:

This time, instead of considering the sides, I am considering the corners. This is another common pattern in the tooltip world. The code structure and the initial configuration remain the same as in the previous examples, so let’s jump straight into the new stuff.

Defining The Positions

If you took the time to explore my interactive demo, you already know the position we will start with:

position-area: top left;

The other positions will logically be top rightbottom left, and bottom right. We already learned that defining explicit positions is not the ideal choice, so let’s flip!

The flipped values are:

position-try-fallbacks: flip-inline, flip-block, flip-block flip-inline; 

The advantage of this configuration is that we are not using flip-start, so we can safely define min-width (or max-height) without issue. The drawback is that adding the tail is complex. It needs to be placed on the corners, and the margin trick won’t work. We need another hack.

Notice how I am using margin instead of inset to control the gap between the tooltip and the anchor. Both are correct, but you will see later why margin is slightly better in my use case.

Adding The Tail

In the previous examples, the logic is to draw a shape with all the tails, then hide the non-needed parts. The tail has the same color as the tooltip and is placed behind its content, so we can only see what is outside the boundary of the tooltip.

This time, I will use a slightly different idea. I am still drawing a shape with all the tails, but the hiding technique will be different.

First, we place the pseudo-element of the tooltip above the anchor. Not on the top of it, but both will overlap each other.

#tooltip::before {
  content: "";
  position: fixed;
  position-anchor: --anchor;
  position-area: center;
  width:  anchor-size(width);
  height: anchor-size(height);
}

I am using a fixed position to be able to “see” the anchor (we talked about this quirk in the first article). Then, I place the element in the center area, which means above the anchor element (or below it depending on the z-index).

I am introducing a new function, anchor-size(), which is part of the Anchor Positioning API. We saw the anchor() function, which allows us to query the position from an anchor element. anchor-size()does the same but with the sizes. I am using it to make the pseudo-element have the same size as the anchor. It’s like using width: 100% where 100% refers to the anchor.

Nothing fancy so far. We have a square behind the anchor.

Let’s increase the size a little so it also touches the tooltip. We add twice the gap defined by the variable --d plus the value of --s, which controls both the radius and the size of the tooltip.

#tooltip {
  --d: .5em; /* distance between anchor and tooltip */
  --s: .8em; /* tail size & border-radius */ 
}

#tooltip:before {
  width:  calc(anchor-size(width) +  2*(var(--d) + var(--s)));
  height: calc(anchor-size(height) + 2*(var(--d) + var(--s)));
}

It seems we are going nowhere with this idea but, believe me, we are almost there.

Now we sculpt the pseudo-element to have the shape of a tail on each corner, like illustrated below:

Illustration showing a blue square transitioning into a tooltip design with four symmetrical tails around a centered anchor icon.

I am using a somewhat verbose clip-path value to create the final shape but the method used is not particularly important. You can consider gradients, SVG background, the new shape() function, etc. Perhaps you would also like to have a different design for the tails. The main idea is to have four tails around the anchor.

Do you start to see the tricks? We have the correct position for the tails (you can drag the anchor and see the result), but we still have to hide the extra ones.

All we need is to add one line of code to the tooltip:

clip-path: inset(0) margin-box;

I know it’s not very intuitive but the explanation is fairly simple. Even if the pseudo-element is using a fixed position and has lost its relation with the tooltip, it remains part of its content, so clipping the tooltip will also affect the pseudo-element.

In our case, the clip-path will consider the margin box as its reference to create a basic rectangle using inset(0) that will show only what is inside it. In other words, anything outside the margin area is hidden.

Toggle the “debug mode” in the demo below and you will see a black rectangle that illustrates the clip-path area.

Only one tail can fit that rectangle, which is perfect for us!

This trick sounds cool! Can’t we apply it to the previous demo as well?

We can! This series of articles could have been one article detailing this trick that I apply to the three examples, but I wanted to explore different ideas and, more importantly, learn about anchor positioning through many examples. Plus, it’s always good to have various methods to achieve the same result.

What about trying to redo the previous example using this technique? Take it as homework to practice what you have learned through this series. You will find my implementation in the next section.

More Examples

Let’s start with the previous demos using the new technique. As usual, you have the debug mode to see what’s going on behind the scenes.

I will conclude with one final example for you to study. You can also try to implement it before checking my code if you want another challenge.

And a version with a curved tail:

Conclusion

I hope you enjoyed this article series. Our goal was to leverage modern CSS to create common tooltip patterns, while also exploring the powerful Anchor Positioning API. It’s one of those modern features that introduce new mechanisms into the CSS world. We are far from the era where we simply define properties and see a static result. Now we can link different elements across the page, create conditional positioning, define a dynamic behavior that adjusts to each situation, and more!

This feature is only at its Level 1. The Level 2 will introduce even more ideas, one of which is the ability to query the fallback positions and apply a custom CSS. Here is one of the previous demos using this future technique:

The code is probably more verbose, but it feels less hacky and more intuitive. I let you imagine all the possibilities you can do with this technique.

Article Series

]]>
https://frontendmasters.com/blog/perfectly-pointed-tooltips-to-the-corners/feed/ 0 7714
Staggered Animation with CSS sibling-* Functions https://frontendmasters.com/blog/staggered-animation-with-css-sibling-functions/ https://frontendmasters.com/blog/staggered-animation-with-css-sibling-functions/#respond Fri, 07 Nov 2025 15:08:18 +0000 https://frontendmasters.com/blog/?p=7631 The CSS functions sibling-index() and sibling-count() return an element’s position relative to its siblings and the total number of siblings, including itself. This is useful for styling elements based on their positions.

<div class="parent"> <!-- sibling-count() = 3 -->
  <div class="child"></div> <!-- sibling-index() = 1 -->
  <div class="child"></div> <!-- sibling-index() = 2 -->
  <div class="child"></div> <!-- sibling-index() = 3 -->
</div>

For instance, to create a pyramid chart, we could proportionally increase the widths of elements as they align.

The integers returned by sibling-index() and sibling-count() can be easily computed with other data types like length, angle, and time. An incremental or decremental time sequence is the foundation of any staggered animation where elements animate consecutively. For example:

.el {
  animation-delay: calc(sibling-index() * 0.1s);
}

This post covers a demo where selecting an item causes the preceding and succeeding items to disappear sequentially from the outside.

The Layout

<main class="cards-wrapper">
  <div class="cards"><input type="checkbox" aria-label="movie, only yesterday"></div>
  <div class="cards"><input type="checkbox" aria-label="movie, the wind rises"></div>
  <div class="cards"><input type="checkbox" aria-label="movie, howl's moving castle"></div>
  <div class="cards"><input type="checkbox" aria-label="movie, ponyo"></div>
  <div class="cards"><input type="checkbox" aria-label="movie, the cat returns"></div>
</main>
.cards-wrapper {
  display: flex;
  gap: 10px;
  justify-content: center;
}

.cards {
  width: 15vw;
  aspect-ratio: 3/4.2;
  contain: layout;
  /* etc. */

  input[type="checkbox"] {
    position: absolute;
    inset: 0;
  }
}

This HTML contains a group of .cards inside a .cards-wrapper, arranged horizontally using flexbox. Each card has a checkbox covering its entire size, triggering selection on click.

The Selectors

Before seeing what happens when a card is selected, let’s see the different CSS selectors we’ll be using to target the different parts of the user interface.

.cards:has(:checked) {

  /* Style rules for the chosen card (the one with a checked box).
    Highlight the chosen card. */   

  .cards-wrapper:has(:checked) .cards:not(&) {
    /* Style rules for the remaining cards when one is chosen.  
      Animate the unchosen cards to disappear. */ 
  }

  & ~ .cards {
    /* Style rules for cards to the right-side of the chosen one. 
       Set a decremental delay time for the disappearance. */

    .cards:not(&) {
       /* Style rules for cards that aren't to the right of the chosen one. 
          Set an incremental delay time. */
    }
  } 
}

The Declarations

Highlight The Chosen Card

The selected card gets a grey dashed border:

border: 2px dashed #888;

Animate The Unchosen Cards to Disappear

opacity: 0;
width: 0; 
display: none; 
transition: 0.3s calc(var(--n) * 0.2s) all;
transition-behavior: allow-discrete;

input { 
  pointer-events: none; 
}
  1. Zero opacity and width create a fade out and horizontal shrinking of the card, including the space the card occupies.
  2. transition-behavior: allow-discrete allows display: none to apply at the end of the transition, which is appropriate as we do want to apply that to remove them from the accessibility tree.
  3. The transition time, affecting all properties that change, is 0.3s. The transition delay is a multiple of 0.2s and the CSS variable --n (to be discussed)
  4. To prevent unchosen cards from being clicked and chosen while disappearing, their checkbox input get pointer-events: none

Decremental Delay Time for Cards On The Right

The cards, succeeding the chosen one, need descending transition delay times. Thus, --n is decremented for each element (left to right) to the right of the chosen card.

--n: calc(sibling-count() - sibling-index() + 1);

Let’s say the third card is chosen. The remaining cards (4 to last) have to vanish in reverse order. The math below shows each of those cards’ --n value and delay time (--n × 0.2s).

Our example has five cards, so sibling-count() is 5. Let’s see how --n calculated for each card after the 3rd (chosen) card:

4th card
--------
--n = calc( sibling-count() - sibling-index() + 1 )
--n = calc( 5 - 4 + 1 )
--n = 2

delay time = calc( var(--n) * 0.2s )
delay time = calc( 2 * 0.2s )
delay time = 0.4s


5th card
--------
--n = calc( sibling-count() - sibling-index() + 1 )
--n = calc( 5 - 5 + 1 )
--n = 1

delay time = calc( var(--n) * 0.2s )
delay time = calc( 1 * 0.2s )
delay time = 0.2s

Incremental Delay Time for Cards On The Left

The sibling-index() value alone is enough, since the delay time of the elements (left to right) to the left of the chosen card increases outward in. First card goes first, then the second, and so forth.

 --n: sibling-index();

Here’s now --n calculates and thus makes the delay time for each card before the 3rd chosen card:

1st card
--------
--n = sibling-index()
--n = 1

delay time = calc( var(--n) * 0.2s )
delay time = calc( 1 * 0.2s )
delay time = 0.2s


2nd card
--------
--n = sibling-index()
--n = 2

delay time = calc( var(--n) * 0.2s )
delay time = calc( 2 * 0.2s )
delay time = 0.4s

The Rulesets

All combined, these are the style rules that are applied when a card is chosen:

/* chosen card */
.cards:has(:checked) {

   border: 2px dashed #888;

  /* cards that aren't chosen, when one has been */
  .cards-wrapper:has(:checked) .cards:not(&) {
    opacity: 0;
    width: 0; 
    display: none; 
    transition: .3s calc(var(--n) * .2s) all;
    transition-behavior: allow-discrete;
    input { pointer-events: none; }
  }

  /* cards after the chosen one */
  & ~ .cards{
    --n: calc(sibling-count() - sibling-index() + 1);
    /* cards not after the chosen one */
    .cards:not(&){
      --n: sibling-index();
    }
  } 
}

Note: Among the “cards not after the chosen one” (see above snippet) the chosen card is also included, but since it doesn’t have the transition declaration, there’s no effect. If you want, you can exclude it by adding :not(:has(:checked)) to the selector, but it’s not necessary in our example.

The Fallback

If a browser doesn’t support sibling-* functions, we can calculate --n in JavaScript to determine the elements’ positions among their siblings.

if(!CSS.supports('order', 'sibling-index()')) {

  // Turn the NodeList to an array for easier manipulation
  const CARDS = Array.from(document.querySelectorAll('.cards'));

  document.querySelector('.cards-wrapper').addEventListener('change', (e) => {

    // Index of the card with the checkbox that fired the 'change' event.
    const IDX = CARDS.indexOf(e.target.parentElement);

    // All cards after the chosen one. If there are three,
    // they get --n values of 3, 2, 1
    CARDS.slice(IDX + 1).forEach((card, idx, arr) 
      => card.style.setProperty('--n', `${arr.length - idx}`));

    // All cards up to the chosen one. If there are three,
    // they get --n values of 1, 2, 3
    CARDS.slice(0, IDX).forEach((card, idx) 
       => card.style.setProperty('--n', `${idx + 1}`));
  });
}

Below is another example, where you’ll be able see an accordion sort of animation by using the sibling-index() function to show and hide the items, even without transition delay, but with the delay the staggered effect comes through.

]]>
https://frontendmasters.com/blog/staggered-animation-with-css-sibling-functions/feed/ 0 7631
The Scope Creep https://frontendmasters.com/blog/the-scope-creep/ https://frontendmasters.com/blog/the-scope-creep/#respond Thu, 06 Nov 2025 23:16:31 +0000 https://frontendmasters.com/blog/?p=7720 The Scope Creep: Pretty fun(ny) Choose Your Own Adventure style game

Assume the role of a humble project manager, tasked with delivering a website brief for one of your agency’s clients. Unbeknownst to you, the project is cursed by a dark force determined to infinitely extend the job, and send you into a delirious state of madness.

The agency that made it has a pretty sweet website, too.

]]>
https://frontendmasters.com/blog/the-scope-creep/feed/ 0 7720
The Weird Parts of position: sticky; https://frontendmasters.com/blog/the-weird-parts-of-position-sticky/ https://frontendmasters.com/blog/the-weird-parts-of-position-sticky/#comments Wed, 05 Nov 2025 18:47:26 +0000 https://frontendmasters.com/blog/?p=7640 Using position: sticky; is one of those CSS features that’s incredibly useful, seemingly simple, and also, frequently frustrating.

The premise is simple: you want to be able to scroll your page’s content, but you want something to “stick” at the top (or anywhere). Frequently, this will be some sort of header content that you want to always stay at the top, even as the user scrolls, but it could be any sort of content (and stick edges other than the top, and at any offset).

We’ll cover a brief introduction to sticky positioning. We’ll see how it works, and then we’ll look at some common, frustrating ways it can fail. Then we’ll learn exactly how to fix it.

For all the code examples I’ll be using Tailwind, and later, a little React/JSX for looping. I know the Tailwind piece might be controversial to some. But for this post it’ll allow me to show everything in one place, without ever requiring you, dear reader, to toggle between HTML and CSS.

Making Content Stick

Let’s look at the simplest possible example of sticky positioning.

<div class="h-[500px] gap-2 overflow-auto">
  <div class="flex flex-col gap-2 bg-gray-400 h-[300px]">
    <span>Top</span>
    <span class="mt-auto">Bottom</span>
  </div>

  <div class="sticky top-0 h-[100px] bg-red-300 mt-2 grid place-items-center">
    <span>I'm sticky!</span>
  </div>

  <div class="flex flex-col bg-gray-400 h-[700px] mt-2">
    <span>Top</span>
    <span class="mt-auto">Bottom</span>
  </div>
</div>

Our middle container has sticky top-0 which sets position: sticky and sets the top value to 0. That means we want it to “stick” at the zero position of whatever scroll container is doing the scrolling.

When Things Go Wrong

This may seem like a simple feature, but in practice it frequently goes wrong, and figuring out why can be maddening. Googling “position sticky doesn’t work” will produce a ton of results, the vast majority of which telling you to make sure you don’t have any containers between your sticky element and your scroll container with overflow: hidden; set. This is true: if you do that, sticky positioning won’t work.

But there are many other things which can go wrong. The next most common remedy you’re likely to see is advising that flex children be set to align-self: flex-start, rather than the default of stretch. This is great advice, and relates strongly to what we’ll be covering here. But in so doing we’re going to dig deep into why this is necessary; we’ll even peak briefly at the CSS spec, and when we’re done, you’ll be well equipped to intelligently and efficiently debug position sticky.

Let’s get started. We’ll look at two different ways you can (inadvertantly) break sticky positioning, and how to fix it.

Problem 1: Your Sticky Element is Bigger Than The Scroll Container

The header above says it all.

The sticky element you want to “stick” cannot be larger than the scrolling container in which it’s attempting to stick.

Let’s see an example:

<div class="h-[500px] gap-2 overflow-auto">
  <div class="flex flex-col gap-2 bg-gray-400 h-[400px]">
    <span>Top</span>
    <span class="mt-auto">Bottom</span>
  </div>
  <div class="sticky top-0 h-[600px] bg-red-300 flex flex-col gap-2 flex-1 mt-2">
    <span>Top</span>
    <span class="mt-auto">Bottom</span>
  </div>
  <div class="flex flex-col gap-2 bg-gray-400 h-[400px] mt-2">
    <span>Top</span>
    <span class="mt-auto">Bottom</span>
  </div>
</div>

Here the scroll container is 500px, and the sticky element is 600px.

This is what the code above renders.

It starts well enough, and the top does in fact stick. But eventually, as you scroll far enough, the browser will ensure that the rest of the sticky element displays in its entirety, which will require the top portion of the element, which had previously “stuck” to the top, to scroll away.

This may seem like a silly example. You probably do want all of your content to show. But this problem can show up in subtle, unexpected ways. Maybe your sticky element is a little too long, but your actual content is in a nested element, correctly constrained. If that happens, everything will look perfect, but inexplicably your sticky element will overshoot at the end of the scrolling. If you see that happening, this might be why!

Problem 2: Your Sticky Element Has a Bounding Context That’s Too Small

Let’s take a look at what the CSS spec has to say (in part) on sticky positioning.

For each side of the box [sticky element], if the corresponding inset property
is not auto, and the corresponding border edge of the box would be outside the
corresponding edge of the sticky view rectangle, then the box must be visually shifted (as for relative positioning) to be inward of that sticky view rectangle edge, insofar as it can while its position box remains contained within its containing block.

Emphasis mine, and that emphasized part refers to the element “sticking.” As the sticky element begins to “violate” the sticky constraints you set (i.e. top: 0;), then the browser forcibly shifts it to respect what you set, and “stick” it in place. But notice the very next line makes clear that this only happens while it can be contained within the containing block.

This is the crucial aspect that the entire rest of this post will obsess over. It manifests itself in many ways (frequently being able to be fixed with “start” alignment rather than “stretch” defaults).

Let’s dive in.

Here’s a sticky demo very similar to what we saw before, except I put the sticky element inside of another element (with a red outline). This immediately breaks the stickyness.

<div class="h-[500px] gap-2 overflow-auto p-1">
  <div class="flex flex-col gap-2 bg-gray-400 h-[400px]">
    <span>Top</span>
    <span class="mt-auto">Bottom</span>
  </div>
  <div class="outline-5 h-[200px] outline-red-500">
    <div class="sticky top-0 h-[200px] bg-red-300 flex flex-col gap-2 flex-1 mt-2">
      <span>Top</span>
      <span class="mt-auto">Bottom</span>
    </div>
  </div>
  <div class="flex flex-col gap-2 bg-gray-400 h-[600px] mt-2">
    <span>Top</span>
    <span class="mt-auto">Bottom</span>
  </div>
</div>

The sticky element is about to stick, but, if the browser were to allow it to do so, it would have to “break out of” its parent. Its parent is not sticky, and so it will keep scrolling. But the browser will not let this “breaking out” happen, so the sticking fails.

Let’s make our parent (with the red outline) a little bigger, so this effect will be even clearer.

<div class="h-[500px] gap-2 overflow-auto p-1">
  <div class="flex flex-col gap-2 bg-gray-400 h-[400px]">
    <span>Top</span>
    <span class="mt-auto">Bottom</span>
  </div>
  <div class="outline-5 h-[300px] outline-red-500">
    <div class="sticky top-0 h-[200px] bg-red-300 flex flex-col gap-2 flex-1 mt-2">
      <span>Top</span>
      <span class="mt-auto">Bottom</span>
    </div>
  </div>
  <div class="flex flex-col gap-2 bg-gray-400 h-[600px] mt-2">
    <span>Top</span>
    <span class="mt-auto">Bottom</span>
  </div>
</div>

Now the sticky element does stick, at first. It sticks because there’s some excess space in its parent. The parent does scroll up, and as soon as the bottom of the parent becomes flush, the sticky element stops sticking. Again, this happens because the browser will not allow a sticky element to stick if doing so would break it out of an ancestor element’s bounds.

This too might seem silly; just don’t do that, you might be thinking. Let’s see a more realistic example of this very phenomenon.

Flex (or Grid) Children

Let’s pretend to build a top-level navigation layout for a web app. Don’t focus on the contrived pieces.

We have a main container, which we’ve sized to 500px (in real life it would probably be 100dvh), and then a child, which itself is a grid container with two columns: a navigation pane on the left, and then the main content section to the right. And for reasons that will become clear in a moment, I put a purple outline around the grid child.

We want the main navigation pane frozen in place, while the main content scrolls. To (try to) achieve this, I’ve set the side navigation to be sticky with top: 0.

Naturally, for this layout, you could achieve it more simply in a way that would work. But a more production ready layout for a real application would be much more complex, and would be much more likely to run into the issue we’re about to see. This entire post is about actual production issues I’ve had to debug and fix, and the learnings therefrom.

export const FlexInFlexStickyDemoVersion1 = () => {
  return (
    <div className="flex border-2 rounded-md">
      <div className="h-[500px] flex flex-1 gap-2 overflow-auto p-1">
        <div className="grid grid-rows-1 outline-2 outline-purple-600 grid-cols-[250px_1fr] flex-1">
          {/* Side Navigation Pane */}
          <div className="sticky top-0 flex flex-col gap-8">
            {Array.from({ length: 5 }).map((_, idx) => (
              <span>Side Navigation {idx + 1}</span>
            ))}
          </div>

          {/* Main Content Pane */}
          <div className="flex flex-1 gap-2">
            <div className="flex flex-col flex-1 gap-2">
              {Array.from({ length: 100 }).map((_, idx) => (
                <div className="flex gap-2">
                  <span>Main Content line {idx}</span>
                </div>
              ))}
            </div>
          </div>
        </div>
      </div>
    </div>
  );
};

And when we run this, the sticky positioning does not work at all. Everything scrolls.

The reason is that our grid child is sized to the container, which means our content cannot stick without “breaking out” of its container (the purple grid), and as we saw, the CSS spec does not allow for this.

Why is this happening? Flex children have, by default, their align-self property set to stretch. That means they stretch in the cross axis and fill up their container. The grid’s parent is a flex container in the row direction.

<div className="h-[500px] flex flex-1 gap-2 overflow-auto p-1">

That means the cross direction is vertical. So the grid grows vertically to the 500px height, and calls it a day. And this is why our stickiness is broken.

Once we understand the root cause, the fix is simple:

export const FlexInFlexStickyDemoVersion1 = () => {
  return (
    <div className="flex border-2 rounded-md">
      <div className="h-[500px] flex flex-1 gap-2 overflow-auto p-1">
        <div className="self-start grid grid-rows-1 outline-2 outline-purple-600 grid-cols-[250px_1fr] flex-1">
          {/* Side Navigation Pane */}
          <div className="self-start sticky top-0 flex flex-col gap-8">
            {Array.from({ length: 5 }).map((_, idx) => (
              <span>Side Navigation {idx + 1}</span>
            ))}
          </div>

          {/* Main Content Pane */}
          <div className="flex flex-1 gap-2">
            <div className="flex flex-col flex-1 gap-2">
              {Array.from({ length: 100 }).map((_, idx) => (
                <div className="flex gap-2">
                  <span>Main Content line {idx}</span>
                </div>
              ))}
            </div>
          </div>
        </div>
      </div>
    </div>
  );
};

We’ve added self-start alignment to both the grid container, and also the sticky element. Adding self-start to the grid tells the grid to start at the start of its flex container, and then, rather than stretch to fill its parent, to just flow as big as it needs to. This allows the grid to grow arbitrarily, so the left pane can sticky without needing to break out of its parent (which, as we’ve seen, is not allowed.)

Why did we add self-start to the sticky element? Remember, grid and flex children both have stretch as the default value for align-self. When we told the grid to grow as large as it needs, then leaving the sticky element as it’s default of stretch would cause it to stretch and also grow huge. That violates our original rule #1 above. Remember when we had a sticky element that was 100px larger than its scrolling container? It stuck only until the last 100px of scrolling. Leaving the sticky element as stretch would cause it to grow exactly as large as the content that’s scrolling, which would prevent it from sticking at all.

What if the side nav gets too big?

Let’s make one more tweak, and stick a green outline on our sticky element.

export const FlexInFlexStickyDemoVersion1 = () => {
  return (
    <div className="flex border-2 rounded-md">
      <div className="h-[500px] flex flex-1 gap-2 overflow-auto p-1">
        <div className="self-start grid grid-rows-1 outline-2 outline-purple-600 grid-cols-[250px_1fr] flex-1">
          {/* Side Navigation Pane */}
          <div className="self-start outline-2 outline-green-600 sticky top-0 flex flex-col gap-8">
            {Array.from({ length: 5 }).map((_, idx) => (
              <span>Side Navigation {idx + 1}</span>
            ))}
          </div>

          {/* Main Content Pane */}
          <div className="flex flex-1 gap-2">
            <div className="flex flex-col flex-1 gap-2">
              {Array.from({ length: 100 }).map((_, idx) => (
                <div className="flex gap-2">
                  <span>Main Content line {idx}</span>
                </div>
              ))}
            </div>
          </div>
        </div>
      </div>
    </div>
  );
};

The self-start alignment on the sticky element keeps its content no bigger than needed. This prevents it from stretching to the (new) grid size that is arbitrarily big. But what happens if our sticky content just naturally gets too big to fit within the scroll container?

It sticks, but as the scroll container gets to the very bottom, the browser un-sticks it, so the rest of its content can scroll and be revealed.

This isn’t actually the worst thing in the world. We probably want to give users some way to see the overflowed side navigation content; but we probably want to just cap the height to the main content, and then make that element scrollable.

export const FlexInFlexStickyDemoVersion1 = () => {
  return (
    <div className="flex border-2 rounded-md">
      <div className="h-[500px] flex flex-1 gap-2 overflow-auto p-1">
        <div className="self-start grid grid-rows-1 outline-2 outline-purple-600 grid-cols-[250px_1fr] flex-1">
          {/* Side Navigation Pane */}
          <div className="max-h-[492px] overflow-auto self-start outline-2 outline-green-600 sticky top-0 flex flex-col gap-8">
            {Array.from({ length: 20 }).map((_, idx) => (
              <span>Side Navigation {idx + 1}</span>
            ))}
          </div>

          {/* Main Content Pane */}
          <div className="flex flex-1 gap-2">
            <div className="flex flex-col flex-1 gap-2">
              {Array.from({ length: 100 }).map((_, idx) => (
                <div className="flex gap-2">
                  <span>Main Content line {idx}</span>
                </div>
              ))}
            </div>
          </div>
        </div>
      </div>
    </div>
  );
};

The weird value of 492 is to allow for the 4px top and bottom padding around it (the p-1 class). In real life you’d of course do something more sensible, like define some CSS variables. But for our purposes this shows what we’re interested in. The side pane is now capped at the containers height, and scrolls if needed.

Parting Thoughts

I hope this post has taught you some new things about position sticky which come in handy someday.

]]>
https://frontendmasters.com/blog/the-weird-parts-of-position-sticky/feed/ 11 7640
Affinity, Free https://frontendmasters.com/blog/affinity-free/ https://frontendmasters.com/blog/affinity-free/#comments Tue, 04 Nov 2025 22:04:11 +0000 https://frontendmasters.com/blog/?p=7695 When people complain about Photoshop or other various Adobe products and the subscription model they require (The Onion had a good one), people tend to reply with two options:

  1. Use GIMP (free, open-source)
  2. Use Affinity (was ~$50, but a one-time cost)

But now, Affinity is free (and all the varieties combined into one app). You can thank the Canva acquisition for that. You might think that would hurt GIMP usage, but I suspect it won’t as GIMP runs on Linux (which neither Adobe or Affinity do) and it feels… Linuxy. I can’t imagine Adobe is happy about it. Canva says “there is absolutely no catch whatsoever”, and also that their mission is to “build one of the world’s most valuable companies” so, ya know, you make the call.

]]>
https://frontendmasters.com/blog/affinity-free/feed/ 1 7695
Perfectly Pointed Tooltips: All Four Sides https://frontendmasters.com/blog/perfectly-pointed-tooltips-all-four-sides/ https://frontendmasters.com/blog/perfectly-pointed-tooltips-all-four-sides/#comments Mon, 03 Nov 2025 16:15:35 +0000 https://frontendmasters.com/blog/?p=7543 Time for part two! We’ve got really nice functional positioned tooltips already, but they were mostly concerned with “pointing” up or down and shifting at the edges to avoid overflow. Now we’re going to take it further, considering four positions without shifts.

Article Series

At the time of writing, only Chrome and Edge have full support of the features we will be using.

Here is a demo of what we are making:

Drag the anchor and see how the tooltip switches between the four positions and how it remains centered relatively to the anchor.

The Initial Configuration

We are going to use the same code structure as in the first part. We start with the tooltip placed above the anchor (the “top”).

<div id='anchor'></div>
<div id='tooltip'></div>
#anchor {
  anchor-name: --anchor;
}
#tooltip {
  --d: 1em; /* distance between tooltip and anchor */

  position: absolute; 
  position-anchor: --anchor;
  position-area: top;
  bottom: var(--d);
}

From here on, things will be different from the previous example.

Defining Multiple Positions

The position-try-fallbacks property allows us to define multiple positions. Let’s try the following:

position-try-fallbacks: bottom, left, right;

Let’s not forget that the placement is related to the containing block, which is the body in our example (illustrated with the dashed border):

We almost have the same behavior as the first example; however if you are close to the right or left edges, you get the new positions. Instead of overflowing, the browser will swap to the right or left position.

Illustration showing a tooltip following an anchor, with a crossed-out example on the left and a correct behavior on the right, displaying the text 'Drag the anchor and I should follow...'

Similar to the first example, the gap disappears when switching to the fallback positions. We know how to fix it! Instead of explicitly defining the positions, we can rely on the “flip” feature.

To move from top to bottom, we use flip-block:

position-try-fallbacks: flip-block, left, right;

From top to left, we use flip-start:

position-try-fallbacks: flip-block, flip-start, right; 

The flip-block value mirrors the position across the horizontal axis, and flip-start does the same across the diagonal. With this value, we can move from top to left and from bottom to right. And logically, we also have a flip-inline that considers the vertical axis to move from left to right.

But how do we move from top to right? We are missing another value, right?

No, we have all the necessary values. To move from top to right, we combine two flips: flip-block to move to the bottom, then flip-start to move to the right:

position-try-fallbacks: flip-block, flip-start, flip-block flip-start;

Or flip-start to move to the left, and then flip-inline to move to the right:

position-try-fallbacks: flip-block, flip-start, flip-start flip-inline;

It should be noted that all the flips consider the initial position defined on the element and not the previous position defined on position-try-fallbacks or the current position. If we first perform a flip-block to move to the bottom, the flip-start of the second position will not consider the bottom position but the top position (the initial one). This can be confusing, especially when you have many positions.

Said differently, the browser will first transform all the flips into positions (considering the initial position) and then pick the suitable one when needed.

Disabling the Shift Behavior

What we have is actually good and might work perfectly for some use-cases, but we’re aiming for slightly more advanced functionality. What we want is to flip to the left or right position as soon as the tooltip touches the edges. We don’t want to have the “shift” behavior. I want the tooltip to remain always centered relatively to the anchor.

Image showing four tooltip positions in relation to an anchor, with text indicating interaction.

For this, we can use:

justify-self: unsafe anchor-center;

What is this strange value!?

After defining the position of an element using position-area we can also control its alignment using justify-self and align-self (or the shorthand place-self). However, we get a default alignment that you rarely need to change.

For position-area: top, the default alignment is equivalent to justify-self: anchor-center and align-self: end.

Don’t we have a center value? Why is it called anchor-center?

The center value exists, but its behavior is different from anchor-center. The center value considers the center of the area, while anchor-center considers the center of the anchor in the relevant axis.

Here is a screenshot taken from my interactive demo, where you can see the difference:

Comparison of element alignment in CSS, showing the difference between centering in the top area versus centering at the anchor point.

In addition to that, anchor-center follows the logic of safe alignment which cause the shift behavior. When there is not enough room for centering, the element will shift to remain within the containing block area. To disable this, we tell the browser to consider an “unsafe” behavior hence the use of:

justify-self: unsafe anchor-center;

Here is a demo with only the top and bottom positions. Notice how the tooltip will overflow from the left and right sides instead of shifting.

And if we add back the left and right positions to the fallbacks, the browser will use them instead of overflowing!

It should be noted that justify-self is also included in the flip. It’s one of those properties that the browser changes when flipping. When the position is top or bottom, it remains justify-self, but when the position is left or right, it becomes align-self. Another reason why it’s better to consider the flip feature instead of explicitly defining a position.

Adding min-width

The position of the tooltip is now good, but in some particular cases, it’s too narrow.

A tooltip with a blue background displaying the text 'Drag the anchor and I should follow...' is positioned above a gray anchor icon.

That’s a logical behavior since the text inside can wrap to make the tooltip fit that position. You probably want to keep that behavior, but in our case, we’d like to add min-width to force it to flip to another position before shrinking too much. It can also be a max-height as well.

Oops, min-width is not preventing wrapping, but it is increasing the height! What?!

Can you guess what the issue is? Think a moment about it.

It’s the flip behavior.

The min-width and all the sizing properties are also affected by the flip. The initial configuration is top, so defining min-width means that when we perform a flip-start to move to the left or the right position, the min-width becomes min-height, which is not good.

So we define min-height instead, when flipped it becomes min-width!

Yes, but the min-height will apply to the top and bottom positions, which is not ideal either.

We can fix this by using custom positions where we define all the properties manually.

#tooltip {
  min-width: 10em;

  position-area: top;
  justify-self: unsafe anchor-center;
  bottom: var(--d);
  position-try-fallbacks: flip-block,--left,--right;
}
@position-try --left {
  position-area: left;
  justify-self: normal;
  align-self: unsafe anchor-center;
  right: var(--d);
}
@position-try --right {
  position-area: right;
  justify-self: normal;
  align-self: unsafe anchor-center;
  left: var(--d);
}

We use @position-try to create a custom position with a given name, and inside it we define all the properties. Instead of using flip-start to set the left position, I define a custom --left position with all the necessary properties to correctly place the tooltip on the left. Same for the right position. In this situation, min-width is preserved for all positions, as we are no longer using flip-start.

It is worth noting that when using a custom position, you need to ensure that you override all the properties of the initial position defined on the element otherwise they still apply. For this reason, I am defining justify-self: normal to override justify-self: unsafe anchor-centernormal being the default value of justify-self.

While this solution works fine, it’s a bit verbose, so I was wondering if we can do better. It turns out we can!

We can combine the flip feature and custom positions to get a shorter code:

#tooltip {
  position-area: top;
  justify-self: unsafe anchor-center;
  bottom: var(--d);
  position-try: flip-block,--size flip-start,--size flip-start flip-inline;
}
@position-try --size {
  min-height: 12em; /* this is min-width! */
}

When we define a custom position with a flip, the browser selects the properties within the custom position, as well as the properties already defined on the element, and then performs the flip. So --size flip-start will flip the properties defined on the element and the one defined in the custom position --sizemin-height becomes a min-width! Clever, right?

But you said we cannot use min-height?

We cannot use it on the main element as it will apply to the top and bottom positions. However, within a custom position, I can select where it applies, and I want it to apply only to the left and right positions. Plus, I don’t need any min-width or min-height constraint when the position is top or bottom.

Now our tooltip position is perfect! Let’s add the tail.

Adding The Tail

First, we create a shape that contains the 4 tails.

Comparison of tooltip shapes demonstrating the transition from a red diamond shape to a blue rounded shape with the text 'Drag the anchor and I should follow...'
#tooltip:before {
  content: "";
  position: absolute;
  z-index: -1;
  inset: calc(-1*var(--d));
  clip-path: polygon(
    calc(50% - var(--s)) var(--d),50% .2em,calc(50% + var(--s)) var(--d),
    calc(100% - var(--d)) calc(50% - var(--s)), calc(100% - .2em) 50%,calc(100% - var(--d)) calc(50% + var(--s)),
    calc(50% + var(--s)) calc(100% - var(--d)),50% calc(100% - .2em),calc(50% - var(--s)) calc(100% - var(--d)),
    var(--d) calc(50% + var(--s)), .2em 50%,var(--d) calc(50% - var(--s))
  );
}

Then we control it using margin on the tooltip element, just as we did in the first part. When the position is top, we add a margin to all the sides except for the bottom one:

margin: var(--d);
margin-bottom: 0;
Comparison of tooltip designs showing a red diamond-shaped tooltip on the left and a blue rectangular tooltip on the right, both displaying the text 'Drag the anchor and I should follow...'.

And for the other sides, we do nothing! The flip will do the job for us.

Toggle the “debug mode” to see how the shape behaves in each position.

Conclusion

We have completed the second part. Now, you should be comfortable working with fallbacks, the flip feature, and custom positions. If you are still struggling, give the article another read. We still have one final challenge, so make sure everything is clear before moving to the next article.

Article Series

]]>
https://frontendmasters.com/blog/perfectly-pointed-tooltips-all-four-sides/feed/ 2 7543
Super Simple Full-Bleed & Breakout Styles https://frontendmasters.com/blog/super-simple-full-bleed-breakout-styles/ https://frontendmasters.com/blog/super-simple-full-bleed-breakout-styles/#comments Fri, 31 Oct 2025 16:06:50 +0000 https://frontendmasters.com/blog/?p=7560 Recently, I saw someone asked on Reddit what others are using these days for full-bleed and breakout elements. This refers to having a main content area of limited width (usually centered), but having the ability for some elements to be wider, either all the way to the browser edges or somewhere in-between.

desired layout at various viewports — notice the image is a full-bleed element, the warning is a breakout element and the header is a  breakout element with a full-bleed background

Is it still the old method that involves stretching elements to 100vw and then moving them in the negative direction of the x axis via an offset, margin, or translation?

Or is it the newer method that involves a grid with a limited width main column in the middle then symmetrical columns on the sides, with elements spanning an odd number of columns that depends on whether we want them to have the normal width of the main column or we want them a bit wider, breaking out of that or we even want them to be full-bleed?

There is no perfectly right answer. It depends on use case and how you look at it. We’re going to look at modified and combined versions and essentially achieve what we need to depending on the situation with modern CSS.

The old method described in the 2016 CSS-Tricks article has the disadvantage of relying on a Firefox bug (that has been fixed since 2017) to work well in all situations. The problem is that 100vw doesn’t take into account any vertical scrollbars we might have (and no, the new viewport units don’t solve that problem either). This leads to the 100vw width elements being wider than the available horizontal space if there is a vertical scrollbar, overflowing and causing a horizontal scrollbar, something I also often see with the bizarre practice of setting the width of the body to 100vw. Now, considering the elements we normally want to be full-bleed are likely images, we can hide the problem with overflow-x: hidden on the html. But it still doesn’t feel quite right.

Maybe it’s because I’m a tech, not a designer who thinks in terms of design grids, but I prefer to keep my grids minimal and when I look at the desired result, my first thought is: that’s a single column grid with the items that are wider than the column, and everything is center-aligned.

So let’s take a look at the approach I most commonly use (or at least start from), which doesn’t involve a scary-looking grid column setup, and, for the simple base cases, doesn’t involve any containers or even any calc(), which some people find confusing.

The Base Grid

We’re starting off with a grid, of course! We set a one limited width column grid on the body and we middle align this grid horizontally within the the content-box of the body:

body {
  display: grid;
  grid-template-columns: min(100%, 60em);
  justify-content: center
}

By default, display: grid creates a one column grid that stretches horizontally across the entire content-box width of the element it’s set on. This makes all the children of the element getting display: grid be distributed in that one column, one on each row. The first on the first row, the second on the second row and so on.

The grid-template-columns property is used here to max out the width of this one column at 60em by setting its width to be the minimum between 100% of the content-box width and 60em. If the content-box of the element we’ve set the grid on has a width of up to 60em, then the one column of the grid stretches horizontally across the entire content-box. If the content-box of the element we’ve set the grid on has a width above 60em, then our one grid column doesn’t stretch horizontally across the entire content-box anymore, but instead stays 60em wide, the maximum width it can take. Of course, this maximum width can be any other value we want.

The justify-content property is used to align the grid horizontally within the content-box of the element it’s set on. In this case, our one grid column is center aligned.

Note that I keep talking about the content-box here. This is because, even at really narrow viewports, we normally want a bit of space in between the text edge and the lateral edge of the available area (the viewport minus any scrollbars we might have). Initially, this space is the default margin of 8px on the body, though I also often do something similar to the approach Chris wrote about recently and zero the default margin to replace it with a clamped font-relative padding. But whichever of them is used still gets subtracted from the available space (viewport width minus any vertical scrollbar we might have) to give us the content-box width of the body.

Now whatever children the body may have (headings, paragraphs, images and so on), they’re all in the limited width grid cells of our one column, something that’s highlighted by the DevTools grid overlay in the screenshot below.

Screenshot. Shows a middle aligned grid with a single column and multiple rows, something that's highlighted by the DevTools-enabled grid overlay.
the one limited width column grid layout with the DevTools grid lines overlay (live demo)

Full-Bleed Elements

Let’s say we want to make an element full-bleed (edge to edge). For example, an image or an image gallery, because that’s what makes the most sense to have stretching all across the entire available page width. This means we want the full viewport width minus any scrollbars we might have.

Nowadays we can get that by making the html a container so that its descendants know its available width (not including scrollbars) as 100cqw (container query width).

html { container-type: inline-size }

Having this, we can create our full-bleed elements:

.full-bleed-elem {
  justify-self: center;
  width: 100cqw
}

Setting width: 100cqw on our full-bleed elements means they get the full available content-box width of the nearest container, which is the html in this case.

The justify-self aligns the element horizontally within its grid-area (which is limited to one grid cell in our case here). We need to set it here because the default is start, which means the left edge of the element starts from the left edge of its containing grid-area. The left edge of the containing grid-area is the same as the left edge of our one column grid here.

Screenshot. Shows a middle aligned grid with a single column and multiple rows, something that's highlighted by the DevTools-enabled grid overlay. On some of these rows, we have full-bleed elements that expand all across the entire available page width (the viewport width minus any vertical scrollbars we might have).
one column grid with full-bleed elements and a DevTools grid overlay highlighting the grid lines

Just like before, we still have a single column grid, center aligned.

One thing to note here is this means we cannot have any margin, border or padding on the html element as any of these would reduce its content-box, whose size is what the container query units are based on. In practice, the margin, border, and padding on the html are all zero by default and I don’t think I’ve seen them set to anything else anywhere outside of some mind-bending CSS Battle solutions.

Another thing to note is that there may be cases where we need another container somewhere in between. In that case, we can still access the content-box width of the html as detailed in a previous article:

@property --full-w {
  syntax: '<length>';
  initial-value: 0px;
  inherits: true;
}

html { container-type: inline-size }

body { --full-w: 100cqw }

.full-bleed-elem {
  justify-self: center;
  width: var(--full-w);
}

Often times, we probably also want some padding on the full-bleed element if it is, for example, an image gallery, but not if it is a single img element.

For img elements, the actual image always occupies just the content-box. Any padding we set on it is empty space around the content-box. This is not generally  desirable in our case. Unless we want to add some kind of decorations around it via the background property (by layering CSS gradients to create some kind of cool pattern, for example), we want the image to stretch all across the available viewport space after accounting for any vertical scrollbar we might have and not be left with empty space on the lateral sides.

Furthermore, if the img uses a box-sizing of content-box, that empty padding space gets added to the 100cqw width of its content-box, making the padding-box width exceed the available space and causing a horizontal scrollbar on the page.

When setting a padding on full-bleed elements, it’s probably best to exclude img elements:

.full-bleed-elem:not(img) { padding: .5em }

Note that in this case, the full-bleed elements getting the padding need to also have box-sizing set to border-box. This is done so that the padding gets subtracted out of the set width and not added as it would happen in the default content-box case.

.full-bleed-elem:not(img) {
  box-sizing: border-box;
  padding: .5em
}

You can see it in action and play with it in the following live demo:

You might be wondering… is it even necessary to set border-box since setting everything to border-box is a pretty popular reset style?

Personally, I don’t set that in resets anymore because I find that with the the new layout options we have, the number of cases where I still need to explicitly set dimensions in general and widths in particular has declined. Drastically. Most of the time, I just size columns, rows, set the flex property instead and let the grid or flex children get sized by those without explicitly setting any dimensions. And when I don’t have to set dimensions explicitly, the box-sizing becomes irrelevant and even problematic in some situations. So I just don’t bother with including box-sizing: border-box in the reset these days anymore and instead only set it in the cases where it’s needed.

Like here, for the non-img full bleed elements.

Another thing you may be wondering about… how about just setting a negative lateral margin?

We know the viewport width minus any scrollbars as 100cqw, we know the column width as 100%, so the difference between the two 100cqw - 100% is the space on the left side of the column plus the space on the right side of the column. This means half the difference .5*(100cqw - 100%), which we can also write as 50cqw - 50%, is the space on just one side. And then we put a minus in front and get our lateral margin. Like this:

.full-bleed-elem {
  margin: .5rem calc(50% - 50cqw);
}

Or, if we want to avoid overriding the vertical margin:

.full-bleed-elem {
  margin-inline: calc(50% - 50cqw);
}

This seems like a good option. It’s just one margin property instead of a justify-self and a width one. And it also avoids having to set box-sizing to border-box if we want a padding on our full-bleed element. But we should also take into account what exactly we are most likely to make full-bleed.

One case we considered here was that of full-bleed images. The thing with img elements is that, by default, they don’t size themselves to fit the grid areas containing them, they just use their own intrinsic size. For full-bleed images this means they are either going to not fill the entire available viewport space if their intrinsic width is smaller than the viewport or overflow the viewport if their intrinsic width is bigger than the available viewport space (the viewport width minus any vertical scrollbar we might have). So we need to set their width anyway.

For the other case, that of the scrolling image gallery, the negative margin can be an option.

Breakout Elements

These are wider than our main content, so they break out of our grid column, but are not full-bleed.

So we would give them a width that’s smaller than the content-box width of the html, which we know as 100cqw, but still bigger than the width of our only grid column, which we know as 100%. Assuming we want breakout elements to extend out on each side by 4em, this means:

.break-elem {
  justify-self: center;
  width: min(100cqw, 100% + 2*4em)
}

Again, we might use a negative lateral margin instead. For breakout elements, which are a lot more likely to be text content elements, the negative margin approach makes more sense than for the full-bleed ones. Note that just like the width, the lateral margin also needs to be capped in case the lateral space on the sides of our column drops under 4em.

.break-elem { margin: 0 max(-4em, 50% - 50cqw) }

Note that we use the max() because for negative values like the margin here, the smaller (minimum) one in absolute value (closer to 0) is the one that’s bigger when looking at the full axis going from minus to plus infinity.

But then again, we might want to be consistent and set full-bleed and breakout styles the same way, maybe grouping them together:

.full-bleed-elem, .break-elem {
  justify-self: center;
  width: min(100cqw var(--comp-w, ));
}

/* This is valid! */
.break-elem { --comp-w: , 100% + 2*4em  }

:is(.full-bleed-elem, .break-elem):not(img) {
  box-sizing: border-box;
  padding: .5em;
}

Some people prefer :where() instead of :is() for specificity reasons, as :where() always has 0 specificity, while :is() has the specificity of the most specific selector in its arguments. But that is precisely one of my main reasons for using :is() here.

And yes, both having an empty default for a CSS variable and its value starting with a comma is valid. Replacing --comp-w with its value gives us a width of min(100cqw) (which is the same as 100cqw) for full-bleed elements and one of min(100cqw, 100% + 2*4em) for breakout elements.

Screenshot. Shows a middle aligned grid with a single column and multiple rows, something that's highlighted by the DevTools-enabled grid overlay. On some of these rows, we have full-bleed images that expand all across the entire available page width (the viewport width minus any vertical scrollbars we might have). On others, we have breakout boxes that expand laterally outside their grid cells, but are not wide enough to be full-bleed.
one column grid with full-bleed and breakout elements, as well as a DevTools grid overlay highlighting the grid lines (live demo)

If we want to have different types of breakout elements that extend out more or less, not all exactly by the same fixed value, we make that value a custom property --dx, which we can change based on the type of breakout element:

.break-elem { --comp-w: , 100% + 2*var(--dx, 4em) }

The --dx value could also be negative and, in this case, the element doesn’t really break out of the main column, it shrinks so it’s narrower.

.break-elem--mini { --dx: -2em }
.break-elem--maxi { --dx: 8em }
Screenshot. Shows a middle aligned grid with a single column and multiple rows, something that's highlighted by the DevTools-enabled grid overlay. One of these rows has a full-bleed image that expands all across the entire available page width (the viewport width minus any vertical scrollbars we might have). On other rows, we have breakout boxes that are not the same width as their grid cells, but are not wide enough to be full-bleed. Most of these boxes are wider than their containing grid cells, but one is narrower.
one column grid with a full-bleed image and various sizes of breakout elements, as well as a DevTools grid overlay highlighting the grid lines (live demo)

Full-Bleed Backgrounds for Limited Width Elements

Sometimes we may want only the background of the element to be full-bleed, but not the element content. In the simplest case, we can do with a border-image and if you want to better understand this property, check out this article by Temani Afif detailing a lot of use cases.

.full-bleed-back {
  border-image: var(--img) fill 0/ / 0 50cqw;
}

This works for mono backgrounds (like the one created for the full-bleed header and footer below with a single stop gradient), for most gradients and even for actual images in some cases.

Screenshot. Shows a middle aligned grid with a single column and multiple rows, something that's highlighted by the DevTools-enabled grid overlay. On the very first row, we have a limited width header with a solid full-bleed mono background. On other rows, we have full-bleed elements that expand all across the entire available page width (the viewport width minus any vertical scrollbars we might have). On other rows, we have breakout boxes that are not the same width as their grid cells, but are not wide enough to be full-bleed.
one column grid that has a tightly fit limited width header with a full-bleed mono background; it also has a full-bleed image and a breakout element, as well as a DevTools grid overlay highlighting the grid lines (live demo)

The mono background above is created as follows (all these demos adapt to user theme preferences):

--img: conic-gradient(light-dark(#ededed, #121212) 0 0)

This method is perfect for such mono backgrounds, but if we want gradient or image ones, there are some aspects we need to consider.

The thing about the 0 50cqw outset value is that it tells the browser to extend the area where the border-image is painted by 50cqw outwards from the padding-box boundary on the lateral sides. This means it extends outside the vewport, but since this is just the border-image, not the border reserving space, it doesn’t cause overflow/ a horizontal scrollbar, so we can keep it simple and use it like this for gradients.

That is, if we can avoid percentage position trouble. While this is not an issue in linear top to bottom gradients, if we want to use percentages in linear left to right gradients or to position radial or conic ones, we need to scale the [0%, 100%] interval to the [50% - 50cqw, 50% + 50cqw] interval along the x axis.

.linear-horizontal {
  --img: 
    linear-gradient(
      90deg, 
      var(--c0) calc(50% - 50cqw), 
      var(--c1) 50%
    );
}

.radial {
  --img: 
    radial-gradient(
      15cqw at calc(50% - 25cqw) 0, 
      var(--c0), 
      var(--c1)
    );
}

.conic {
  --img: 
    conic-gradient(
      at calc(50% + 15cqw), 
      var(--c1) 30%, 
      var(--c0), 
      var(--c1) 70%
    );
}

However, this scaling is not enough for linear gradients at an angle that’s not a multiple of 90°. And it may be overly complicated even for the types of gradients where it works well.

So another option is compute how much the border-image needs to expand laterally out of the available horizontal space 100cqw and the maximum grid column width --grid-w. This then allows us to use percentages normally inside any kind of gradient, including linear ones at an angle that’s not a multiple of 90°.

body {
  --grid-w: 60em;
  display: grid;
  grid-template-columns: min(100%, var(--grid-w));
  justify-content: center;
}

.full-bleed-back {
  border-image: 
    var(--img) fill 0/ / 
    0 calc(50cqw - .5*var(--grid-w));
}
Screenshot. Shows a middle aligned grid with a single column and multiple rows, something that's highlighted by the DevTools-enabled grid overlay. On the very first row, we have a limited width header with a solid full-bleed gradient background. On other rows, we have full-bleed elements that expand all across the entire available page width (the viewport width minus any vertical scrollbars we might have). On other rows, we have breakout boxes that are not the same width as their grid cells, but are not wide enough to be full-bleed.
one column grid that has a tightly fit limited width header with a full-bleed angled gradient background (at an angle that’s not a multiple of 90°); it also has a full-bleed image and a breakout element, as well as a DevTools grid overlay highlighting the grid lines (live demo)

This has a tiny problem that other styling decisions we’re likely to take (and which we’ll discuss in a moment) prevent from happening, but, assuming we don’t make those choices, let’s take a look at it and how we can solve it.

full-bleed background issue on narrow viewports

On narrow viewports, our background isn’t full-bleed anymore, it stops a tiny distance away from the lateral sides. That tiny distance is at most the size of the lateral margin or padding on the body. As mentioned before, I prefer to zero the default margin and use a font-size-relative padding, but in a lot of cases, it doesn’t make any difference whatsoever.

Screenshot collage. Shows the top area of the page with the header in both the dark and light theme cases at a narrow viewport width of 400px. It also highlights the fact that the header's full-bleed background isn't quite full-bleed, but stops a tiny distance away from the lateral sides.
the problem in the narrow viewport case, highlighted for both the dark and the light themes

This happens when the maximum grid column width --grid-w doesn’t fit anymore in the available viewport space (not including the scrollbar) minus the lateral spacing on the sides of our one column grid (set as a margin or padding).

The solution is to use a max() instead of the calc() to ensure that the border-image expands laterally at the very least as much as that lateral spacing --grid-s.

body {
  --grid-w: 60em;
  --grid-s: .5em;
  display: grid;
  grid-template-columns: min(100%, var(--grid-w));
  justify-content: center;
  padding: 0 var(--grid-s);
}

.full-bleed-back {
  border-image: 
    var(--img) fill 0/ / 
    0 max(var(--grid-s), 50cqw - .5*var(--grid-w));
}
fix for full-bleed background issue on narrow viewports (live demo)

For actual images however, we have an even bigger problem: border-image doesn’t offer the cover option we have for backgrounds or images and we don’t really have a reliable way of getting around this. One of the repeat options might work for us in some scenarios, but I find that’s rarely the case for the results I want in such situations.

You can see the problem in this demo when resizing the viewport — for an element whose height is unknown as it depends on its content, the border-image option (the second one) means that if we want to avoid the image getting distorted, then its size needs to be intrinsic size. Always. It never scales, which means it repeats for large viewports and its sides get clipped off for small viewports.

So if we want more control over an image background or multiple background layers, it’s probably better to use an absolutely positioned pseudo-element. This also avoids the earlier problem of the full-bleed background not going all the way to the edges without taking into account the lateral spacing on the grid container (in this case, the body).

.full-bleed-back-xtra {
  position: relative;
  z-index: 1
}

.full-bleed-back-xtra::before {
  position: absolute;
  inset: 0 calc(50% - 50cqw);
  z-index: -1;
  content: ''
}

The inset makes our pseudo to stretch across the entire padding-box of its parent vertically and outside of it (minus sign) by half the available viewport space (viewport width minus any scrollbars) minus half the pseudo parent’s width.

The negative z-index on the pseudo ensures it’s behind the element’s text content. The positive z-index on the element itself ensures the pseudo doesn’t end up behind the grid container’s background too.

The pseudo background can now be a cover image:

background: var(--img-pos, var(--img) 50%)/ cover

I’m taking this approach here to allow easily overriding the background-position together with each image if necessary. In such a case, we set --img-pos:

--img-pos: url(my-back-img.jpg) 35% 65%

Otherwise, we only set --img and the default of 50% gets used:

--img-pos: url(my-back-img.jpg)

In the particular case of our demos so far, which use a light or dark theme to respect user preferences, we’ve also set a light-dark() value for the background-color, as well as an overlay blend mode to either brighten or darken our full-bleed background depending on the theme. This ensures the header text  remains readable in both scenarios.

Screenshot. Shows a middle aligned grid with a single column and multiple rows, something that's highlighted by the DevTools-enabled grid overlay. On the very first row, we have a limited width header with a solid full-bleed image background. On other rows, we have full-bleed elements that expand all across the entire available page width (the viewport width minus any vertical scrollbars we might have). On other rows, we have breakout boxes that are not the same width as their grid cells, but are not wide enough to be full-bleed.
one column grid that has a tightly fit limited width header with a full-bleed image background; it also has a full-bleed image and a breakout element, as well as a DevTools grid overlay highlighting the grid lines (live demo)

We can also have multiple layers of gradients, maybe even blended, maybe even with a filter making them grainy (something that would help with the visible banding noticed in the border-image method examples) or creating a halftone pattern.

Screenshot. Shows a middle aligned grid with a single column and multiple rows, something that's highlighted by the DevTools-enabled grid overlay. On the very first row, we have a limited width header with a solid full-bleed multi-gradient, filtered background. On other rows, we have full-bleed elements that expand all across the entire available page width (the viewport width minus any vertical scrollbars we might have). On other rows, we have breakout boxes that are not the same width as their grid cells, but are not wide enough to be full-bleed.
one column grid that has a tightly fit limited width header with a filtered full-bleed multi-layer background; it also has a full-bleed image and a breakout element, as well as a DevTools grid overlay highlighting the grid lines (live demo)

Combining options

We can of course also have a breakout element with a full-bleed background – in this case, we give it both classes, break-elem and full-bleed-back.

Our recipe page header for example, probably looks better as a breakout element in addition to having a full-bleed background.

If the breakout elements in general have a border or their own specific background, we should ensure these don’t apply if they also have full-bleed backgrounds:

.break-elem:not([class*='full-bleed-back']) {
  border: solid 1px;
  background: var(--break-back)
}

Or we can opt to separate these visual prettifying styles from the layout ones. For example, in the Halloween example demos, I’ve opted to set the border and background styles via a separate class .box:

.box {
  border: solid 1px var(--c);
  background: lch(from var(--c) l c h/ .15)
}

And then set --c (as well as the warning icon in front) via a .box--warn class.

Screenshot. Shows a middle aligned grid with a single column and multiple rows, something that's highlighted by the DevTools-enabled grid overlay. On the very first row, we have a breakout header (wider than its containing grid cell, but not wide enough to be full-bleed) with a solid full-bleed multi-gradient, filtered background. On other rows, we have full-bleed elements that expand all across the entire available page width (the viewport width minus any vertical scrollbars we might have). On other rows, we have breakout boxes.
one column grid that has a breakout header with a filtered full-bleed multi-layer background; it also has a full-bleed image and a breakout element, as well as a DevTools grid overlay highlighting the grid lines (live demo)

Another thing to note here is that when having a full-bleed background for a breakout element and we use the border-image tactic, we don’t have to adapt our formula to take into account the lateral spacing, as that’s set as a padding on the breakout element and not on its grid parent.

The most important of these techniques can also be seen in the meta demo below, which has the relevant CSS in style elements that got display: block.

Nesting

We may also have a figure whose img is full-bleed, while the figcaption uses the normal column width (or maybe it’s a breakout element).

<figure>
  <img src='full-bleed-img.jpg' alt='image description' class='full-bleed-elem'>
  <figcaption>image caption</figcaption>
</figure>

Not much extra code is required here.

The simple modern solution is to make the img a block element so that the justify-self property set via the .full-bleed-elem middle aligns it even if it’s not a grid or flex item.

img.full-bleed-elem { display: block }

However, support for justify-self applying to block elements as per the current spec is still limited to only Chromium browsers at the moment. And while the Firefox bug seems to have had some activity lately, the Safari one looks like it’s dormant.

So the easy cross-browser way to get around that without any further computations is to make the figure a grid too in this case.

figure:has(.full-bleed-elem, .break-elem) {
  display: grid;
  grid-template-columns: 100%;
  width: 100%;
}
Screenshot. Shows a middle aligned grid with a single column and multiple rows, something that's highlighted by the DevTools-enabled grid overlay. This grid has a figure that is tightly fit inside its grid cell, but also has a full-bleed image spreading across the entire available horizontal space (the viewport width minus any vertical scrollbars) we might have. On other rows, we have full-bleed elements or breakout boxes (wider than their containing grid cells, but still not wide enough to be full-bleed on wide screens). We also have a combination that's a breakout header with a full-bleed background.
one column grid that has a figure, tightly fit horizontally within its containing column, but with a full-bleed image; there’s also a DevTools grid overlay highlighting the grid lines (live demo)

Floating Problems

This is a problem that got mentioned for the three column grid technique and I really didn’t understand it at first.

I started playing with CSS to change the look of a blog and for some reason, maybe because that was what the first example I saw looked like, I got into the habit of putting any floated thumbnail and the text next to it into a wrapper. And it never occurred to me that the wrapper wasn’t necessary until I started writing this article and looked into it.

Mostly because… I almost never need to float things. I did it for those blog post thumbnails fifteen years ago, for shape-outside demos, for drop caps, but that was about it. As far as layouts go, I just used position: absolute for years before going straight to flex and grid.

This was why I didn’t understand this problem at first. I thought that if you want to float something, you have to put it in a wrapper anyway. And at the end of the day, this is the easiest solution: put the entire content of our one column in a wrapper. In which case, until justify-self applying on block elements works cross-browser, we need to replace that declaration on full-bleed and breakout elements with our old friend margin-left:

margin-left: calc(50% -50cqw)

This allows us to have floated elements inside the wrapper.

Screenshot. Shows a middle aligned grid with a single column and multiple rows, something that's highlighted by the DevTools-enabled grid overlay. This grid has a single grid child that is tightly fit inside its containing column and acts as a wrapper for full-bleed elements, breakout boxes (wider than their containing grid cells, but still not wide enough to be full-bleed on wide screens), combinations of these like a breakout header with a full-bleed background. But this wrapper also allows its children to be floated.
one column grid that has a single grid child, tightly fit horizontally within its containing column and acting as a wrapper for the entire page content; since this wrapper has no flex or grid layout, its children can be floated (live demo)

Final Thoughts: Do we even really need grid?

At this point, getting to this floats solution begs the question: do we even really need grid?

It depends.

We could just set lateral padding or margin on the body instead.

I’d normally prefer padding in this case, as padding doesn’t restrict the background and sometimes we want some full viewport backdrop effects involving both the body and the html background.

Other times, we may want a background just for the limited width of the content in the middle, in which case margin on the body makes more sense.

If we want to be ready for both situations, then we’re better off with not setting any margin or padding on the body and just wrapping all content in a limited width, middle aligned (good old max-width plus auto margins) main that also gets a background.

At the same time, my uses cases for something like this have never involved using floats and have benefitted from other grid features like gaps, which make handling spacing easier than via margins or paddings.

So at the end of the day, the best solution is going to depend on the context.

]]>
https://frontendmasters.com/blog/super-simple-full-bleed-breakout-styles/feed/ 1 7560