Skip to main content
Posts

CSS Pseudo-indicators

Inspired by a tweet about Radix UI tabs not supporting a dynamic indicator like Base UI, I had an idea to fake one with Tailwind trickery. It mostly works.

Here's the end result:

Demo
#1

Sure, this is trivial with Motion or Base UI, but if you're using Radix or have a component library that doesn't support this, you can spruce up your tabs with a little CSS. Here we're using Tailwind for expediency, but plain old CSS will do the trick. No additional JavaScript required.

The trick is to animate an ::after element on each trigger, then use adjacent sibling selectors to reposition the indicators when a given trigger is active.

Basically, we want each tab to have its own indicator element. When the tab is active, the indicator lines up with its parent. And when a different tab is active, we move the indicator to line up with that tab.

Synchronization

If we sync them up correctly, the active tab's indicator will move toward the new tab at the same time the new tab's indicator does. When the two indicators meet, we crossfade them, and it looks like as if a single indicator is moving between siblings.

Here's that same example unskewed, so you can see how they overlap when adjacent siblings are selected:

Handoff

Thankfully, Tailwind has an abstraction for this kind of thing: group and peer. We can use these classes to make adjacent triggers "hand off" the indicator from one trigger to another by flipping the animation direction based on whether the previous trigger is active or not.

Step-by-step

Before we do anything, let's consider the possible positions for the indicator. We're trying to animate the indicator in from the left when a previous tab is active, and in from the right when a later tab is active. So, we have three possible positions:

  1. Active: lined up with its trigger
  2. If a previous sibling is active: lined up with trigger's previous sibling
  3. If a later sibling is active: lined up with the trigger's next sibling

   ░░2░░ ←----          │     ---→ ▒▒2▒▒ ←----   │         ---→  ░░2░░
                        │                        │                    
   ╔═══╗ ┌ ─ ┐ ┌ ─ ┐    │    ┌ ─ ┐ ╔═══╗ ┌ ─ ┐   │   ┌ ─ ┐ ┌ ─ ┐ ╔═══╗
   ║ 1 ║ | 2 | | 3 |    │    │ 1 │ ║ 2 ║ │ 3 │   │   │ 1 │ │ 2 │ ║ 3 ║
   ╚═══╝ └ ─ ┘ └ ─ ┘    │    └ ─ ┘ ╚═══╝ └ ─ ┘   │   └ ─ ┘ └ ─ ┘ ╚═══╝

   1 active;                  2 active;               3 active;
   indicator 2 moves left     indicator centered      indicator 2 moves right

To start off, we'll create an indicator as a pseudo-element on our trigger, and park it past the right edge of our trigger with translate-x-full. When active, we also want the indicator to slide in from the right, so we set origin-right on its initial state.

Park the pseudo-element

Next, target the active/pressed state. We'll snap translateX back to 0 when the trigger is pressed. This moves the indicator atop our trigger on click. Be sure to add some duration and easing to the transformation.

Active state snaps into place
Location
after
If the indicator obscures your trigger, give it a negative z-index (i.e., .-z-1), and throw .isolate on the parent to create an independent stacking context.

It's important to note that the peer class is only aware of previous siblings. So, we'll use peer-data-pressed to move the indicator into position whenever any of the triggers to the left are pressed.

So, let's swap its position when a peer is pressed. We also want to change the transform-origin property when we swap sides, so that the indicator animates in from the correct side.

The two classes we need to add are after:-translate-x-full and after:origin-left, each prefixed with the peer-data-pressed selector.

Peer state changes the parked side
Location
after
Origin
right

Let's refine this by adding a transition to our indicator, and have it fade in/out as it moves.

Fade the handoff
Visibility
hidden
Location
before
Origin
left

You may be wondering how we account for cases where subsequent siblings are active if we can't target them with peer...well, we don't have to! Since we initially set the indicator's position to the right of the trigger, it just hangs out at the edge of the next sibling when neither of the first two conditions are met.

We can string this together with 3+ triggers, and give each one its own indicator.

Every trigger owns an indicator
Visibility
hidden
Location
before
Origin
right

Now, all we need to do is give them matching styles and line them up. This should give the illusion of a single indicator in the final version.

Single-color illusion

Final result

Pill tabs
#1

For underline tabs, all we need to do is change the height and positioning of the indicators.

Underline indicator
#1

And for vertical tabs, just swap the orientation and translate + origin directions of the indicators from x/left/right to y/top/bottom.

Vertical tabs
#1

Gotchas

A few things to watch out for when using this approach:

  • Tailwind v4 maps translate-* to the translate property, not transform, so target your transitions with transition-[translate]
  • If you have a long tab label followed by a much shorter one, you may see the indicator's extra width briefly appear as it animates across the shorter tab. You can mitigate this by delaying the opacity transition ever-so-slightly.
  • If using a high-contrast color for your indicator, you may more readily see the indicator at the margins as it animates in. Delay both the opacity and background-color transitions in this case until it feels right.
  • If you want to avoid these altogether, just have the tabs stretch to fill the tab list.

Agent Instructions

If you're handing this off to an agent, you can give it the behavioral recipe instead of the full markup:

agent-instructions.txt