Frontend Masters Boost RSS Feed https://frontendmasters.com/blog Helping Your Journey to Senior Developer Mon, 10 Nov 2025 19:45:55 +0000 en-US hourly 1 https://wordpress.org/?v=6.8.3 225069128 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 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
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
Perfectly Pointed Tooltips: A Foundation https://frontendmasters.com/blog/perfectly-pointed-tooltips-a-foundation/ https://frontendmasters.com/blog/perfectly-pointed-tooltips-a-foundation/#comments Tue, 28 Oct 2025 16:51:29 +0000 https://frontendmasters.com/blog/?p=7514 Tooltips are a classic in web development. You click on an element, and a small bubble appears to display additional details. Behind that simple click, there tends to be JavaScript performing calculations to determine the correct position for the tooltip. Let’s try to place it at the top. Nope, not enough space. Let’s try the bottom instead. It’s also touching the right edge so let’s shift it a bit to the left. There is a lot that can go into making sure a tooltip is placed well without any cutoffs losing information.

In this article, I will show you how to write good JavaScript that covers all the possibilities…

Kidding! We’ll be using CSS and I will show how the modern anchor positioning API can help us with all this. None of the weight and performance concerns of JavaScript here.

Article Series

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

Let’s start with a demo:

Click-and-drag the anchor and see how the tooltip behaves. It will try to position itself in a way to remain visible and avoid any overflow. Cool, right? No JavaScript is used to position the tooltip (except the one for dragging the anchor, which is irrelevant to the trick).

This is made possible thanks to the new Anchor Positioning API and a few other tricks we will dissect together. We will also study more examples, so if you are new to anchor positioning, you are in the right place.

The Initial Configuration

Let’s start with the markup: An anchor element and its tooltip:

<div id='anchor'></div>
<div id='tooltip'></div>

This isn’t interesting HTML, but it does showcase how the anchor and the tooltip are different elements that don’t need to be parent/child. They can be anywhere in the DOM and the CSS can handle that (though, for practical and accessibility reasons, you may want to keep them close together and associate them).

The HTML structure you use will depend on your use case and your type of content, so choose it carefully. In all cases, it’s mainly one element for the anchor and another one for the tooltip.

Here is a demo taken from another article where the anchor is the thumb of a slider and the tooltip is an <output> element:

The CSS:

#anchor {
  anchor-name: --anchor;
}
#tooltip {
  position: absolute; 
  position-anchor: --anchor;
  position-area: top;
}

We define the anchor using anchor-name, link the tooltip to the anchor using position-anchor (and a custom ident, the --anchor bit that looks like a custom property but is really just a unique name), and then we place it at the top using position-area. The tooltip needs to be absolutely positioned (which includes fixed position as well).

Nothing fancy so far. The tooltip is “always” placed at the top, whatever the anchor’s position. You can drag the anchor to see the result.

In this article we’ll use simple values for position-area, but this property can be very tricky.

A grid layout demo showing various cell configurations and an anchor icon with the text 'CSS Is Awesome' positioned at the top left.
I’ve created an interactive demo if you want to explore all the different values and understand how alignment works in the context of Anchor Positioning.

Now that our tooltip is placed, let’s add a small offset at the bottom to prepare the space for the tail. Using bottom will do the job.

#anchor {
  anchor-name: --anchor;
}
#tooltip {
  --d: 1em; /* distance between tooltip and anchor */

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

Making the Position Dynamic

Let’s move to the interesting part, where we will adjust the position of the tooltip to make it always visible and avoid any overflow. Anchor Positioning has native mechanisms to do this, and all we need to do is understand how to use them correctly.

The first thing is to identify the containing block of the absolutely positioned element. We may intuitively think that the logic is to avoid a screen overflow, but that’s not the case. It’s related to the containing block. This can be very confusing if you don’t understand this part, so let’s look closely.

The specification:

Anchor positioning, while powerful, can also be unpredictable. The anchor box might be anywhere on the page, so positioning a box in any particular fashion might result in the positioned box overflowing its containing block or being positioned partially off screen.

To ameliorate this, an absolutely positioned box can use the position-try-fallbacks property to refer to several variant sets of positioning/alignment properties that the UA can try if the box overflows its initial position. Each is applied to the box, one by one, and the first that doesn’t cause the box to overflow its containing block is taken as the winner.

As you can read, it’s all about the containing block, and the containing block of an absolutely positioned element is the first ancestor with a position different from static (the default). If such element doesn’t exist we consider the initial containing block.

In our example, I am going to use the body as the containing block, and I will add a border and an offset from each side to better illustrate:

Drag the anchor to the left or the right and see what happens. When the tooltip touches the edges, it stops, even if you can still move the anchor. It overflows the body only when the anchor is getting outside.

The browser will initially place the tooltip at the top and centered. The priority is to remain withing the containing block, so if there isn’t enough space to keep the center behavior, the tooltip is shifted. The second priority is to keep the anchor behavior, and in this case, the browser will allow the overflow if the anchor element is outside.

A three-part interactive demo showing a tooltip following an anchor element as it is dragged. The tooltip displays the message 'Drag the anchor and I should follow...' with an anchor icon below.

Assuming the anchor will remain within the body area, we already have what we want without too much effort. The tooltip will never overflow from the right, left, or bottom side. What remains is the top.

By default, the browser can shift the element within the area defined by position-area, but cannot do more than that. We need to instruct the browser on how to handle the other cases. For this, we use position-try-fallbacks where we define different positions for the browser to “try” in case the element doesn’t fit its containing block.

Let’s define a bottom position:

position-try-fallbacks: bottom;

Drag the anchor to the top and see what happens:

Now, when the tooltip overflows the body from the top, the position becomes “bottom”. It will also remain bottom until the tooltip overflows again from the bottom. In other words, when the browser picks a position after an overflow, it keeps it until a new overflow happens.

That’s all, we are done! Now our tooltip is perfectly placed, whatever the anchor position.

But we no longer have the gap when the position is at the bottom (for the future arrow). How do we fix that?

We told the browser to only change the value of position-area to bottom, but we can do better by using:

position-try-fallbacks: flip-block;

“Block” refers to the block axis (the vertical axis in our default writing mode), and this instruction means flip the position across the vertical axis. The logic is to mirror the initial position on the other side. To do this the browser needs to update different properties in addition to position-area.

In the example we’ve defined position-area: top and bottom: var(--d). With position-try-fallbacks: flip-block in place, when that flip happens, it’s as if we defined position-area: bottom and top: var(--d). We keep the gap!

If you are a bit lost and confused, don’t worry. We are dealing with new mechanisms not common to the CSS world so it may take time to click for you.

To sum up, we can either instruct the browser to update only the position-area by defining a new position or to “flip” the actual position across one axis which will update different properties.

Adding The Tail

Adding a tail to a tooltip is pretty straightforward (I even have a collection of 100 different designs), but changing the direction of the tail based on the position is a bit tricky.

Three tooltip examples illustrating text that says 'Drag the anchor and I should follow...' with an anchor icon, showcasing dynamic positioning.

For now, Anchor Positioning doesn’t offer a way to update the CSS based on the position, but we can still use the existing features to “hack” it. Hacking with CSS can be fun!

I am going to rely on the “flip” feature and the fact that it can update the margin to achieve the final result.

First, I will consider a pseudo-element to create the tail shape:

#tooltip {
  --d: 1em; /* distance between tooltip and anchor */
  --s: 1.2em; /* tail size */
}
#tooltip::before {
  content: "";
  position: absolute;
  z-index: -1;
  width: var(--s);
  background: inherit;
  inset: calc(-1*var(--d)) 0;
  left: calc(50% - var(--s)/2);
  clip-path: polygon(50% .2em,100% var(--d),100% calc(100% - var(--d)),50% calc(100% - .2em),0 calc(100% - var(--d)),0 var(--d));
}

Both tails are visible by default. Click “debug mode” to better understand the shape and how it’s placed.

When the tooltip is at the top, we need to hide the top part. For this, we can use a margin-top on the pseudo-element equal to variable --d. And when the tooltip is at the bottom, we need margin-bottom.

I am going to define the margin on the tooltip element and then inherit it on pseudo-element:

#tooltip {
  --d: 1em; /* distance between tooltip and anchor */
  --s: 1.2em; /* tail size */

  margin-top: var(--d);
}
#tooltip::before {
  margin: inherit;
}

Tada. Our tooltip is now perfect! The use of margin will hide one side keeping one tail visible at a time.

But we didn’t define margin-bottom. How does it work for the bottom position?

That’s the “flip” feature. Remember what we did with the gap where we only defined top and flip-block changed it to bottom? The same logic applies here with margin: the margin-top automatically becomes a margin-bottom when the position is flipped! Cool, right?

Note that using margin will cause the tooltip to flip a bit earlier since margin is part of the element, and the logic is to prevent the overflow of the “margin box”. It’s not a big deal in our example; it’s nicer to flip the position before it touches the edges.

Moving The Tail

The top and bottom parts are good, but we still need to fix the cases where the tooltip shifts when it’s close to the left and right edges. The tail needs to follow the anchor. For this, we have to update the left value and make it follow the anchor position.

Instead of:

left: calc(50% - var(--s));

We use:

left: calc(anchor(--anchor center) - var(--s)/2); 

I replace 50%, which refers to the center of the tooltip element, with anchor(--anchor center), which is the center of the anchor element.

The anchor() function is another cool feature of Anchor Positioning. It allows us to query a position from any anchor element and use it to place an absolutely positioned element.

Uh oh — that doesn’t work. I’ve left this in though, as it’s a educational moment we need to look at.

We hit one of the trickiest issues of Anchor Positioning. In theory, any element on the page can be an anchor using anchor-name and any other element can position itself relative to that anchor. That’s the main purpose of the feature but there are exceptions where an element cannot reference an anchor.

I won’t detail all the cases, but in our example, the pseudo-element (the tail) is a child of the tooltip, which is an absolutely positioned element. This makes the tooltip the containing block of the pseudo-element and prevents it from seeing any anchor defined outside it. (If you think z-index and stacking context are hard, get ready for this)

To overcome this, I will update the position of the pseudo-element to fixed. This changes its containing block (the viewport at the moment) and makes it able to see the anchor element.

Yes, the demo is broken, but drag the anchor close to the edges and see how the tail is correctly placed horizontally as it’s now able to “see” the anchor element. However, the pseudo-element now has a fixed position so it can no longer be placed relatively to its parent element, the tooltip. To fix this we can make the tooltip an anchor element as well, so the pseudo-element can reference it.

In the end we need two anchors: #anchor and #tooltip. The tooltip is positioned relatively to the anchor, and the tail is positioned relatively to both the anchor and the tooltip.

#anchor {
  position: absolute;
  anchor-name: --anchor;
}
#tooltip {
  --d: 1em;  /* distance between anchor and tooltip  */
  --s: 1.2em; /* tail size */
  
  position: absolute; 
  position-anchor: --anchor;
  anchor-name: --tooltip;
}
/* the tail */
#tooltip:before {
  content: "";
  position: fixed;
  z-index: -1;
  width: var(--s);
  /* vertical position from tooltip  */
  top:    calc(anchor(--tooltip top   ) - var(--d));
  bottom: calc(anchor(--tooltip bottom) - var(--d));
  /* horizontal position from anchor */
  left: calc(anchor(--anchor center) - var(--s)/2);
}

Thanks to anchor(), I can retrieve the top and bottom edges of the tooltip element and the center of the anchor element to correctly position the pseudo-element.

Our tooltip is now perfect! As I mentioned in the introduction, this CSS is not particularly complex. We barely used 20 declarations.

What if we want to start with the bottom position?

Easy! You simply change the initial configuration to consider the bottom position and flip-block will switch to the top one when there is an overflow.

#tooltip {
  position-area: bottom; /* instead of position-area: top; */
  top: var(--d); /* instead of bottom: var(--d); */
  margin-bottom: var(--d); /* margin-top: var(--d) */
}

Conclusion

That’s all for this first part. We learned how to place a tooltip using position-area and how to defined a fallback position when an overflow occurs. Not to mention the flip feature and the use of the anchor() function.

In the second part, we will increase the difficulty by working with more than two positions. Take the time to digest this first part before moving to the next one I also invite you to spend a few minutes on my interactive demo of position-area to familiarize yourself with it.

Article Series

]]>
https://frontendmasters.com/blog/perfectly-pointed-tooltips-a-foundation/feed/ 3 7514
For Your Convenience, This CSS Will Self-Destruct https://frontendmasters.com/blog/for-your-convenience-this-css-will-self-destruct/ https://frontendmasters.com/blog/for-your-convenience-this-css-will-self-destruct/#respond Wed, 22 Oct 2025 22:53:33 +0000 https://frontendmasters.com/blog/?p=7492 In A Progressive Enhancement Challenge, I laid out a situation where the hardest thing to do is show a button you never want to show at all if the JavaScript loads and executes properly. I wrote of this state:

It seems like the ideal behavior would be “hide the interactive element for a brief period, then if the relevant JavaScript isn’t ready, show the element.” But how?! We can’t count on JavaScript for this behavior, which is the only technology I’m aware of that could do it. Rock and a hard place!

Scott Jehl blogged For Your Convenience, This CSS Will Self-Destruct, including an idea that fits the bill. It’s a @keyframes animation that hides-by-default then fades in after 2s. With this in place, in your JavaScript, you’d include a bit that ensures the button stays hidden, with a new class. That’s a win!

… a site’s JavaScript files can often take many seconds to load and execute, so it’s great to have something like this ready to bail out of anything fancy in favor of giving them something usable as soon as possible.

]]>
https://frontendmasters.com/blog/for-your-convenience-this-css-will-self-destruct/feed/ 0 7492
The Two Button Problem https://frontendmasters.com/blog/the-two-button-problem/ https://frontendmasters.com/blog/the-two-button-problem/#comments Tue, 21 Oct 2025 23:16:01 +0000 https://frontendmasters.com/blog/?p=7422 I see this UI/UX issue all the time: there are two buttons, and it’s not clear which one of them is in the active state. Here’s an example from my TV:

Which one of those two buttons above is active? Like if I press the enter/select button on my remote, am I selecting “Try it free” or “Sign in”? It’s entirely unclear to me based on the design. Those two design styles are ambiguous. Just two random selections from today’s trends of button design.

If I press the up (or down) arrows on my remote, the styles of the buttons just reverses, so even interacting with the interface doesn’t inform the choice.

This is a problem that can be solved at multiple levels. If the buttons are toggles that affect on-page content, the accessibility angle is partially solved by the aria-selected attribute, for example. It’s also slightly less of an issue on devices with a cursor, as you likely just click on the one that you want. This is mostly a problem with remote control and keyboard usage where the active state is unclear or ambiguous.

I call it the “two button” problem because if there were more than two buttons, the one that is styled differently is probably the one that is active. We could use our grade school brains to figure out which button is active.

(via)

Ideally, though, we don’t have to think very hard. It should be obvious which one is active.

Again, the problem:

The most obvious solution here is to make both button styles the same, but be additive when one of them is the active button.

I feel like it’s very clear now that “Try it free” is the selected button now. Even if it’s not to a user immediately. If they tab/arrow/whatever to the other button, that outline design will move to it and it will become clear then.

You could also, ya know, literally point to it:

Perhaps you could resort to more “extreme” design styles like this when there is prove-ably no mouse/cursor involved, like:

@media (hover: none) and (pointer: coarse) {
  button:active {
    /* big obvious arrow styles */
  }
}

We’ve got a recent post on @media queries that goes into lots of situations like this!

This “two button” problem also can come up in the design pattern of “toggles”. Take something like this:

A “pill” toggle design pattern.

Which one of those buttons is currently active? The up arrow? The down arrow? Neither? It’s impossible to tell by look alone.

Sometimes in this case the “active” button is “grayed out”:

The implication here is that the up arrow is the “active” one, so you don’t need to press it again as it won’t do anything. Only the non-active button is pressable. I feel like this is okay-ish as a pattern, but it’s not my favorite as the active state is less prominent instead of more prominent almost indicating it’s disabled for some other reason or doesn’t matter.

This kind of thing makes me almost miss the days of skeuomorphism where digital interfaces were designed to try to mimic real world surfaces and objects. We don’t have to go full leather-coated buttons though, we can just make the active button appear pressed through shadows and flattening.

This situation differs from the TV interface issue above in that this “active” button is indicating the button has already been pressed, not that it’s the button that will be pressed. So you’d need a style for that state as well.

Maybe these aren’t the most amazing examples in the world, but I hope I’ve got you thinking about the two-button problem. When there are only two buttons, you can’t just pick two arbitrary different styles, as that doesn’t help people understand which of the two are active. You need to think about it a little deeper and get those styles in a place where it’s obvious.

]]>
https://frontendmasters.com/blog/the-two-button-problem/feed/ 5 7422
A11Y Linting HTML with CSS https://frontendmasters.com/blog/a11y-linting-html-with-css/ https://frontendmasters.com/blog/a11y-linting-html-with-css/#respond Tue, 14 Oct 2025 15:51:12 +0000 https://frontendmasters.com/blog/?p=7418 Will Mendes has a bit of CSS to highlight accessibility issues on HTML elements. Things like missing alt text and labels that aren’t linked properly to inputs. If you want to try it out quick on a website, I wrapped it in a little injection JavaScript so you could paste it in the console wherever.

Console Code
document.head.insertAdjacentHTML("beforeend", `<style>html:not([lang]),
html[lang=""] {
  border: 2px dotted red !important;
}

/*  highlight images missing alt text */
img:not([alt]) {
  filter: blur(5px) !important;
}

/* highlight on all elements that are inside of lists but not a list item <li> and displays them with a red outline.*/
:is(ul, ol) > *:not(li) {
  outline: 2px dotted red !important;
}

/* highlight on links without valid href attribute */
a:not([href]),
a[href="#"],
a[href=""],
a[href*="javascript:void(0)"] {
  outline: 2px dotted red !important;
}

/* highlights label with invalid for attribute */
label:not([for]),
label[for="#"],
label[for=""] {
  border: 2px dotted red !important;
}

/* Avoids div buttons from hell. More details in https://www.htmhell.dev/2-div-with-button-role/ */
div[role="button"] {
  color: red;
  text-decoration: blink !important;
}

/* highlight on empty anchors/buttons */
button:empty,
a:empty {
  border: 2px dotted red !important;
}</style>`);

Not that you should be pasting code that strangers give you into the console. But ya know, we’re cool.

]]>
https://frontendmasters.com/blog/a11y-linting-html-with-css/feed/ 0 7418
Modern CSS Round-Out Tabs https://frontendmasters.com/blog/modern-css-round-out-tabs/ https://frontendmasters.com/blog/modern-css-round-out-tabs/#comments Mon, 13 Oct 2025 15:56:32 +0000 https://frontendmasters.com/blog/?p=7381 Quite a while back I made a set of “round out” tabs, where the literal tab part of the UI would connect to the content below with a rounded edge that flared out as it connected. A bit tricky of a situation, even now!

That old school solution used four additional elements per tab. Two to place a square on the bottom edges of the tab, and then larger circles to hide everything but the flared part.

Illustration showing a tab design with rounded edges, featuring a central tab with additional shapes for visual effects. The background consists of different shades and shapes, emphasizing the tab structure.

Here’s that (again: old!) demo:

Let’s Use shape() Instead

I’m so hyped on shape(). It’s an amazing addition to CSS, giving us a primitive that can draw, well, anything you could draw with a pen tool.

In our case we’re going to use the shape() primitive with clip-path to carve a tab shape out of a rectangle. No extra elements!

.tab {
  clip-path: shape(
    /* do commands to cut out a tab shape */
  );
}

The shape() function takes all these commands to do the drawing. Depending on how complex a thing you are trying to do, the syntax is fairly human-readable.

Let’s slowly walk through hand-building this tab shape. It’ll be extra cool because:

  1. It’s not completely fixed shape. Parts of it can be fixed coordinates, and other parts can be flexible. You’ll see, it’s awesome.
  2. We can variablize it, meaning we can adjust the look on the fly.

1) Starting Out!

Elements start out as rectangles. Ours are going to be horizontally longer rectangles just by virtue of them having text in them pushing them that direction. Then a bit of padding pushing those inline edges more than the block side edges.

.tab {
  display: inline-block; /* So <a> will take padding */
  padding: 0.5rem 2rem;
  white-space: nowrap; /* a wrapped tab will look silly */
  
  clip-path: shape(
    from bottom left,
  );
}

We wanna start carving away at this tab with clip-path from the bottom left corner, so here we go.

2) The First Curve!

Right away we need to curve into the tab shape. This is beautiful right away, as this is the “round out” part that is hard to pull off. Ain’t no border-shape can really help us here, we’re fancy people.

.tab {
  ...
  
  clip-path: shape(
    from bottom left,
    curve to 10px calc(100% - 10px) with 10px 100%,
  );
}

3) Straight up!

We could use syntax (line) here saying “draw a straight line to these new coordinates”, but I think it’s more satisfying here to use syntax (vline) saying “whatever horizontal coordinate you’re at doesn’t matter, just draw to this new vertical coordinate”.

.tab {
  ...
  
  clip-path: shape(
    from bottom left,
    curve to 10px calc(100% - 10px) with 10px 100%,
    vline to 10px
  );
}

4) Curve to the Top!

We’ll use the same curve command here as the first curve, where we specify where we’re going and a point the curve should use to sorta pull toward.

Honestly I tried using arc commands here first (like arc to 20px 10px of 20%) but by default the arc curved “the wrong way” making a bite shape and I didn’t really get what 20% meant. I’m absolutely sure it’s possible and maybe a smidge easier, I just thought curve made more sense to me.

.tab {
  ...
  
  clip-path: shape(
    from bottom left,
    curve to 10px calc(100% - 10px) with 10px 100%,
    vline to 10px,
    curve to 20px 0 with 10px 0,
  );
}

5) Moving to the Other Side!

This is my favorite point on the whole shape.

Again instead of specifying an exact coordinate, we’re just saying draw horizontally from wherever you are to 20px away from the right edge.

We don’t know how far away the last point and this new point are away from each other. They could be 200px away, 117.23px away, 0px away, the line could even draw to the left because the element is so narrow. That’s good. We’re drawing a shape here with points that are a combination of fixed positions (e.g. 10px from the top!) and flexible positions (20px away from whatever the right edge is!).

.tab {
  ...
  
  clip-path: shape(
    from bottom left,
    curve to 10px calc(100% - 10px) with 10px 100%,
    vline to 10px,
    curve to 20px 0 with 10px 0,
    hline to calc(100% - 20px),
  );
}

6) Draw the Rest of the Owl

From here, I think you get the point. We’re going to:

  1. Curve back downward.
  2. Draw the vertical line.
  3. Curve to complete the round-out.

We don’t need to draw a line back to the start of the shape. That’s just implied magically.

.tab {
  ...
  
  clip-path: shape(
    from bottom left,
    curve to 10px calc(100% - 10px) with 10px 100%,
    vline to 10px,
    curve to 20px 0 with 10px 0,
    hline to calc(100% - 20px),
    curve to calc(100% - 10px) 10px with calc(100% - 10px) 0,
    vline to calc(100% - 10px),
    curve to 100% 100% with calc(100% - 10px) 100%
  );
}

That complete’s our shape! The white areas here are what is “cut away” leaving the yellow area (just for visualization):

The padding we’ve set in the inline direction (2rem) is plenty to survive from being clipped away, as we’re only clipping ~10px away.

Variablizing Things

Hmmmmmmm.

Notice we used 10px and awful lot in our shape(). We used a couple of 20px values too, and the intention was clearly “twice as much as that other value”. So we could get away with setting a custom property to 10px and using that repetitively.

.tab {
  --tabGirth: 12px;

  clip-path: shape(
    from bottom left,
    curve to var(--tabGirth) calc(100% - var(--tabGirth)) with
      var(--tabGirth) 100%,
    vline to var(--tabGirth),
    curve to calc(var(--tabGirth) * 2) 0 with var(--tabGirth) 0,
    hline to calc(100% - calc(var(--tabGirth) * 2)),
    curve to calc(100% - var(--tabGirth)) var(--tabGirth) with
      calc(100% - var(--tabGirth)) 0,
    vline to calc(100% - var(--tabGirth)),
    curve to 100% 100% with calc(100% - var(--tabGirth)) 100%
  );
}

The Modern Demo

I added a few doo-dads to the final demo. The hover and active states push the tabs down a little with translate, for instance. That’s nothing to write home about, but then I wanted to rudimentary overflow: auto behavior so the non-wrapping tabs didn’t blow out horizontally, and it led to this:

The horizontal scrollbar is what I wanted, but the vertical scrollbar is like: no.

So I enjoyed the fact that can now (sorta) do single-directional overflow control:

/*
  Allow horizontal scrollbars, but
  hide vertical overflow
*/
overflow-inline: auto;
overflow-block: clip;

I also used Knobs to give a UI control to the CSS variable --tabGirth so you can see how the tabs look with different values. The more girth almost the smaller the tabs look, because we need to “cut away” more of the tab.

There is a smidge of other trickery in there like getting shadows via filter on a parent element, that even work with the clip-path.

Fallbacks

Not every browser supports shape() at the time of this writing (there is even sub-support issues of syntax features).

But that doesn’t mean we have to deliver them entirely rectangular tabs. A @supports test allows us to deliver a fallback just fine. We just need to pass in a valid shape syntax (you can’t just do shape()).

.tab {
  ...
  
  @supports not (clip-path: shape(from top left, hline to 0)) {
    /* less padding needed inline */
    padding-inline: 1rem; 
    
    /* top rounding */
    border-start-start-radius: var(--tabGirth);
    border-start-end-radius: var(--tabGirth);
  }
}

Accessibility of Tabs

The tabs are built from anchor links that jump-link to the related content. When JavaScript is active, they get what I think are the correct roles and aria-* attributes. The aria-attributes are updated when I think is the appropriate time to the appropriate values.

But I’m sure this isn’t fully correct. Just having anchor links here means the arrow keys to change tabs don’t work, which I think is a general requirement of tabs. So anyway this is mostly about the design of the tabs and you’d be better off consulting elsewhere for perfectly accessible implementations of the behavior.

Other Examples

I looked around at a number of older examples and a lot of them involve pseudo or extra elements and have aged like milk. Despite the modern browser support requirements here, I expect the above will age much better, as will these more modern takes below:

]]>
https://frontendmasters.com/blog/modern-css-round-out-tabs/feed/ 2 7381