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.

Measured indicator against an anchored oneSame visual behaviour; different amount of code and failure modes.Measured indicator against an anchored onegetBoundingClientRect + inline stylesMeasures on select, resize and font loadForced layout each timeDrifts if a measurement is missedScript owns the geometryanchor() + anchor-size()Selection moves the anchor nameBrowser resolves geometry in layoutFollows scrolling tab bars for freeCSS owns the geometry
Same visual behaviour; different amount of code and failure modes.

Step-by-step resolution

Building the indicatorThree declarations and a fallback.Building the indicator1Set position: relative on the tab list.A containing block for the indicator2Apply anchor-name: --active-tab to [aria-selected=true].The anchor follows selection3Position the indicator with anchor(left), anchor-size(width) and anchor(bottom).Matches the selected tab4Transition left and width with the move token.The underline slides5Add an @supports fallback using translate and a width custom property.Works without anchor positioning6Remove the transition under reduced motion.
Three declarations and a fallback.

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 left and width animate; composite per frame for the fallback, which animates translate and scale. 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.

Main-thread work per selection change12-tab bar on a mid-range laptop, including the slide.Main-thread work per selection changeMeasured, remeasured on resize and fonts3.4 msAnchored, left and width transition1.1 msAnchored, no transition0.2 ms
12-tab bar on a mid-range laptop, including the slide.

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 left and width is 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.