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:
Sure, this is trivial with Motion and layout animations...and, sure, you soon won't need this once Base UI is stable...but in the meantime, 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.
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:
- Active: lined up with its trigger
- If a previous sibling is active: lined up with trigger's previous sibling
- If a later sibling is active: lined up with the trigger's next sibling
Style a pseudo-element as the indicator and park it past the right edge with translate-x-full. Add origin-right for later, since we want it to animate in from the right when activated.
When active, we want the indicator to slide in from the right. So, we set origin-right initially on the ::after.
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.
.-z-1), and throw .isolate on the parent to create an independent stacking context.It's important to note that the peerclass 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.
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.
So, let's swap its position when a peer is pressed by adding peer-data-pressed:after:-translate-x-full.
Let's refine this by adding a transition to our indicator, and have it fade in/out as it moves.
Now, we'll string it all together and give each toggle its own indicator. Notice anything?
Two things are wrong here.
- When we press a previous toggle, the current indicator slides in from the right like we want. But when we press a later one, it's sliding in from the wrong side.
- And you'll also notice that the "old" indicator is animating away from our new trigger when we press a previous one.
The cause of both issues is the same. We forgot to swap the transform-origin when a peer is pressed.
Here's that same example unskewed, so you can see how they overlap when adjacent siblings are selected:
All we need to is set them to the same color and restore the initial fade when not in view. That should give the illusion of a single indicator in the final version.
Final result
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 withtransition-[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.