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.
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.
Active-tab indicator · :has() driven
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
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.
iOS · filled rounded pill
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.
Material · bottom dot + bar
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.
Animated tabs keep native radio semantics for selection and move a decorative indicator with transform from a container-level active index.
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 {35position: absolute;inline-size: 1px;block-size: 1px;opacity: 0;}.tabs-recipe label {41position: 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>
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.
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.
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.
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.
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.
Remove the slide transition, not the selected state. The active tab should update immediately with color, background, and focus styling intact.
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.
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).
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.
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.
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.
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.