Sliding Tab Indicators with Anchor Positioning
Part of CSS Anchor Positioning & Motion in Modern View Transitions & Scroll APIs.
The problem
A tab bar has an underline that slides to the selected tab and resizes to its width. The usual implementation measures the selected tab with getBoundingClientRect(), writes translate and width as inline styles, and repeats the measurement on resize, on font load, when tabs change label, and when the tab bar itself scrolls horizontally. Each of those is a forced layout, and a missed one leaves the underline under the wrong tab.
The underline’s position is entirely determined by the selected tab’s box. CSS can express that directly.
Root cause analysis: the indicator is a function of the anchor’s box
anchor-name marks the selected tab. Applying anchor-name: --active-tab through the [aria-selected="true"] selector means the name moves with selection, without script touching the indicator at all.
anchor() and anchor-size() resolve the anchor’s geometry. left: anchor(left) and width: anchor-size(width) make the indicator exactly as wide as the selected tab and aligned to its left edge. When selection changes, the anchor changes, and those computed values change.
Transitions animate the change. With transition: left, width on the indicator, the new computed values are interpolated, producing the slide. That is a layout animation — left and width are layout properties, so the browser lays the indicator out each frame — but the indicator is a single small absolutely positioned element, so the work is negligible. Support for interpolating anchor-derived values is newer than anchor positioning itself, which is why a fallback matters.
The containing block must be the tab list. Absolutely positioned elements resolve insets against their containing block. position: relative on the tab list keeps anchor(left) meaningful relative to the bar rather than the page.
Scrollable tab bars keep working. In a horizontally scrollable tab list, the anchor moves as the bar scrolls and the indicator follows without script — the case that breaks most measured implementations.
Step-by-step resolution
Production code pattern
.tabs {
position: relative; /* containing block for the indicator */
display: flex;
gap: 0.5rem;
overflow-x: auto;
}
.tabs [role="tab"][aria-selected="true"] {
anchor-name: --active-tab; /* the name moves with selection */
}
.tabs__indicator {
position: absolute;
position-anchor: --active-tab;
left: anchor(left);
width: anchor-size(width);
bottom: anchor(bottom);
block-size: 3px;
border-radius: 999px;
background: var(--color-primary-600);
transition:
left var(--motion-move-duration) var(--motion-move-easing),
width var(--motion-move-duration) var(--motion-move-easing);
}
/* Fallback: script sets --tab-x and --tab-w; transform-only, compositor-friendly. */
@supports not (anchor-name: --a) {
.tabs__indicator {
left: 0;
width: 1px;
translate: var(--tab-x, 0) 0;
scale: var(--tab-w, 1) 1;
transform-origin: left;
transition:
translate var(--motion-move-duration) var(--motion-move-easing),
scale var(--motion-move-duration) var(--motion-move-easing);
}
}
@media (prefers-reduced-motion: reduce) {
.tabs__indicator { transition: none; }
}
// Fallback only: measure once per selection change when anchor positioning is unavailable.
const supportsAnchor = CSS.supports('anchor-name: --a');
function syncIndicator(list) {
if (supportsAnchor) return;
const tab = list.querySelector('[aria-selected="true"]');
const bar = list.getBoundingClientRect();
const r = tab.getBoundingClientRect();
list.style.setProperty('--tab-x', `${r.left - bar.left + list.scrollLeft}px`);
list.style.setProperty('--tab-w', String(r.width));
}
Rendering Impact: layout per frame for the anchored version, because
leftandwidthanimate; composite per frame for the fallback, which animatestranslateandscale. The indicator is a few pixels tall and absolutely positioned, so its layout cost is trivial either way.
The fallback’s scale trick — a 1px-wide element scaled horizontally — keeps the animation on the compositor, which is why it is worth having even where anchor positioning is supported: on very long tab bars with dozens of tabs, a compositor animation avoids per-frame layout entirely. The trade-off is that a scaled element’s border radius scales too, which is visible on rounded indicators.
Scrollable tab bars and focus
When the tab list scrolls horizontally, selecting a tab that is partly off screen should bring it into view. scrollIntoView({ inline: 'nearest' }) on the selected tab does that, and because the indicator is anchored, it follows without any additional work. Under reduced motion, pass behavior: 'auto' so the scroll is instant, matching smooth scrolling under reduced motion. The indicator is decorative: the selected state must be conveyed by aria-selected on the tab, never by the underline alone.
Verification checklist
Constraints and trade-offs
- Animating
leftandwidthis layout per frame; acceptable for one small element, not for many. - Interpolating anchor-derived values is newer than anchor positioning; test in target browsers.
- The scale-based fallback distorts rounded corners and borders.
- Tab labels that change width while selected cause the indicator to resize mid-interaction.
- An indicator anchored to a tab inside a scroller inherits that scroller’s clipping.
Frequently asked questions
How do I move a tab underline without measuring tabs in JavaScript?
Apply anchor-name to the selected tab and position the indicator with anchor() and anchor-size(), then transition left and width.
Is animating left and width expensive here?
It is layout per frame, but the indicator is one small absolutely positioned element, so the cost is minimal. Use a transform-based fallback if you need compositor-only motion.
Does the indicator follow a horizontally scrolling tab bar?
Yes. The anchor moves with the tab, and the browser keeps the anchored element attached without script.
What happens in browsers without anchor positioning?
Use @supports to fall back to a measured transform: the script sets custom properties on selection only, rather than on every scroll.
Related
- CSS Anchor Positioning & Motion — the parent topic
- Tab Panel Transitions with Types — animating the panels the tabs control
- FLIP Technique for Layout Animations — the general measured-motion technique