← Back to gallery
CSS

Animated Tabs

Tabbed nav with a single sliding underline indicator driven by a CSS custom property updated per click (or via :has() in supporting browsers). Three indicator styles: thin underline, pill highlight, and a notch + dot Material tab.

tabsactive-indicatorcss-custom-propertieshas-selectoraria-selectedrole-tabprefers-reduced-motion

Active-tab indicator · :has() driven

Animated Tabs

Three production tab-indicator animations — Underline Slide (a thin underline that translates across active tabs), Pill Tab (an iOS-style filled rounded pill that slides under the active label), and Notch Indicator (a Material-style bottom dot + bar that travels). Each one is driven by container-level custom properties via :has() so the indicator position updates without JavaScript when the active tab changes; a class-toggle fallback covers browsers without :has().

Baseline · thin underline translates

Underline Slide

The most-used tab indicator: a thin underline (2px) that translates across the active tab. Width = 100/N% so it always matches the slot. The slide is composited via transform: translateX, so the indicator stays butter-smooth even with text-heavy tabs.

  • translateX
  • 2px underline
  • baseline

iOS · filled rounded pill

Pill Tab

iOS-style segmented tab where the active tab is highlighted with a full-height pill background that slides between slots. Active label brightens against the pill; inactive labels stay dim. The pill itself has a soft accent glow to lift it off the bar.

  • filled pill
  • active label brightens
  • iOS-style

Material · bottom dot + bar

Notch Indicator

Material-style indicator: a small dot + a thin bar at the bottom of the active tab. The dot is the saturated accent; the bar is a subtle anchor that frames the dot. The whole indicator block translates between tab centres.

  • bottom dot
  • thin bar
  • Material-style

Tab indicator inspector

Underline Slide

click tab
Underline Slide
  • translateX
  • 2px underline
  • baseline

The most-used tab indicator: a thin underline (2px) that translates across the active tab. Width = 100/N% so it always matches the slot. The slide is composited via transform: translateX, so the indicator stays butter-smooth even with text-heavy tabs.

Helped you ship something? 🐟 Send my cat a churu

/* Thin 2px underline translates across the active tab via translateX(--tab-active × 100%). */
.tab-bar {
  position: relative;
  display: grid;
  grid-template-columns: repeat(4, 1fr);
}

.tab-bar input { position: absolute; opacity: 0; pointer-events: none; }
.tab-bar label { padding: 12px 14px; text-align: center; cursor: pointer; }
.tab-bar input:checked + label { color: #7dd3fc; }

.tab-bar::after {
  content: '';
  position: absolute;
  bottom: 0;
  left: 0;
  width: calc(100% / 4);
  height: 2px;
  background: #7dd3fc;
  transform: translateX(calc(100% * var(--tab-active, 0)));
  transition: transform 0.26s cubic-bezier(0.2, 0.8, 0.2, 1);
}

/* :has() drives --tab-active from the checked radio's index. */
.tab-bar:has(input:nth-of-type(2):checked) { --tab-active: 1; }
.tab-bar:has(input:nth-of-type(3):checked) { --tab-active: 2; }
.tab-bar:has(input:nth-of-type(4):checked) { --tab-active: 3; }

How to make this

Animated tabs keep native radio semantics for selection and move a decorative indicator with transform from a container-level active index.

html
1<fieldset class="tabs-recipe">  <legend>Dashboard section</legend>  <input id="tab-overview" name="tabs-recipe" type="radio" checked>  <label for="tab-overview">Overview</label>  <input id="tab-reports" name="tabs-recipe" type="radio">  <label for="tab-reports">Reports</label>  <input id="tab-alerts" name="tabs-recipe" type="radio">  <label for="tab-alerts">Alerts</label>  <input id="tab-settings" name="tabs-recipe" type="radio">  <label for="tab-settings">Settings</label></fieldset> <style>.tabs-recipe {15  --tab-count: 4;  --tab-active: 0;  position: relative;  display: grid;  grid-template-columns: repeat(var(--tab-count), 1fr);  width: min(100%, 26rem);  margin: 0;  padding: .25rem;  border: 1px solid #334155;  border-radius: 999px;  background: #0f172a;}.tabs-recipe legend {  position: absolute;  inline-size: 1px;  block-size: 1px;  overflow: hidden;  clip: rect(0 0 0 0);}.tabs-recipe input {35  position: absolute;  inline-size: 1px;  block-size: 1px;  opacity: 0;}.tabs-recipe label {41  position: relative;  z-index: 1;  padding: .65rem .75rem;  color: #94a3b8;  text-align: center;  cursor: pointer;  transition: color 240ms ease;}.tabs-recipe input:checked + label { color: #f8fafc; }50.tabs-recipe input:focus-visible + label {  outline: 2px solid #7dd3fc;  outline-offset: 2px;  border-radius: 999px;}.tabs-recipe::after {  content: "";  position: absolute;  inset: .25rem auto .25rem .25rem;  width: calc((100% - .5rem) / var(--tab-count));  border-radius: 999px;  background: rgba(125, 211, 252, .22);  box-shadow: inset 0 0 0 1px rgba(125, 211, 252, .5);  transform: translateX(calc(100% * var(--tab-active)));  transition: transform 260ms cubic-bezier(.2, .8, .2, 1);}66.tabs-recipe:has(#tab-reports:checked) { --tab-active: 1; }.tabs-recipe:has(#tab-alerts:checked) { --tab-active: 2; }.tabs-recipe:has(#tab-settings:checked) { --tab-active: 3; }69@supports not selector(:has(*)) {  .tabs-recipe::after { display: none; }  .tabs-recipe input:checked + label { background: rgba(125, 211, 252, .18); }}73@media (prefers-reduced-motion: reduce) {  .tabs-recipe::after,  .tabs-recipe label { transition: none; }}</style>

Annotated snippet

  1. Line 1The tab group uses real radio inputs inside a fieldset. The indicator is decorative; selection still comes from native form controls.
    PitfallShould animated tabs be buttons or radios?

    Use the semantic control that matches the behavior. For mutually exclusive local views, radio inputs with labels are a good paste-and-run CSS scaffold. In app code, ARIA tabs can also work if keyboard behavior and selected state are implemented completely.

  2. Line 15The container owns --tab-active and --tab-count. That keeps indicator math in one place instead of duplicating offsets on every label.
  3. Line 35The inputs are visually hidden, not display: none. Labels can still toggle them, and keyboard focus can still land on the checked option.
  4. Line 41Labels sit above the moving indicator with z-index: 1. The selected text color changes from the checked radio, not from the indicator position.

    Only the label z-index changes here. Without it, the indicator paints over the text as it slides; with it, the labels stay readable through the whole sweep.

  5. Line 50The indicator width is one equal tab slot and moves with transform. Avoid animating left; transform stays composited and does not re-run layout.

    Simulated: animating left on the indicator can drop to ~5fps on slow devices, looking choppy like the left card. transform sits on the compositor and stays smooth.

    PitfallWhat is the performance risk in tab indicators?

    left and width are layout properties — every animation frame triggers layout for the whole tab bar (and anything sized off it) before paint. On a dense dashboard the indicator can drop from 60fps to under 10fps under scroll or resize. transform sits on the composite layer, so the slide runs on the GPU with zero layout cost. Same visual endpoint, far less main-thread work.

  6. Line 66:has() maps each checked radio to a numeric active index. A class-based or JS-set custom property fallback can use the same indicator CSS.
    PitfallIs :has() safe for animated tabs?

    Modern Chromium, Safari, and Firefox support :has(), but a static fallback is still useful. Keep selected label styling independent from the moving indicator so unsupported browsers still show the active tab.

  7. Line 69The @supports branch removes only the animated indicator. The checked label still has a static background so state remains visible.
  8. Line 73Reduced motion disables the slide transition while preserving instant selection, label color, and focus rings.
    PitfallWhat should prefers-reduced-motion do for tabs?

    Remove the slide transition, not the selected state. The active tab should update immediately with color, background, and focus styling intact.

Other pitfalls

Why does my tab indicator drift out of alignment?
The indicator width and transform stride must use the same slot math. Set width to one slot, then translate by 100% of the indicator width for each active index. Mixing pixel offsets and percentage widths causes drift.

Notes

Overview

Tab indicators slide an underline (or pill, or notch) under the active tab when the user clicks. Three variants ship: a thin underline that translates X, a filled pill that slides with rounded corners, and a Material-style notch with a dot. The indicator can be driven by :has() in supporting browsers, or by a CSS custom property updated per click in vanilla JS for the rest.

When to use it

Reach for animated tabs on settings panels, profile sections, anything with three-to-six equally-important categories. Skip tabs for more than six categories — nav becomes a scroll surface that collapses the tab metaphor. Skip them for binary toggles (use a switch instead) and for primary navigation (use real links instead so the URL is shareable).

How it works

Tabs render as <button role="tab" aria-selected> with a sibling indicator element. Two implementation paths: the :has() path lets CSS query .tabs:has(button[aria-selected="true"]:nth-child(N)) .indicator to position the indicator without any JS; the vanilla path writes --active-index on a wrapper element on each click, then the indicator uses transform: translateX(calc(var(--active-index) * 100%)) with a transition. Both rely on equal-width tab buttons so the indicator math stays simple; for variable-width tabs measure each button’s offset on click and set an explicit --indicator-x + --indicator-w.

Production gotchas

The :has() selector is supported across all modern engines as of 2023–2024, but if you must support older Firefox, gate the rule behind @supports selector(:has(*)) and fall back to the JS path. Variable-width tabs that re-measure on resize need an ResizeObserver on the tablist or the indicator drifts as the user zooms. The transition must cancel mid-flight if the user clicks rapidly through tabs — without an animation-timing-function that resolves quickly, the indicator overshoots its current target.

Accessibility

Follow the WAI-ARIA tabs pattern: arrow keys move focus between tabs without activating (manual activation pattern) or with activation (automatic activation pattern). Choose automatic only when tab content loads instantly; otherwise users mashing arrow keys trigger expensive renders. Each panel needs role="tabpanel" + aria-labelledby bound to its tab. Under prefers-reduced-motion: reduce drop the indicator transition so it snaps directly to position.

References

Implementation depth

The active indicator should follow semantic tab state. aria-selected and focus management tell users where they are; the underline or pill animation simply makes the state transition easier to track.

Keep panel changes honest. If content swaps instantly, the indicator can animate without delaying access to the new panel. Reduced motion should snap the indicator and leave keyboard navigation unchanged.