Focus Highlights That Follow Anchors

Part of CSS Anchor Positioning & Motion in Modern View Transitions & Scroll APIs.

The problem

A sidebar navigation has a soft rounded highlight behind the current item. Design wants it to glide between items as the user arrows through them. The first implementation animates each item’s own background-color with a transition, which cross-fades rather than glides. The second moves a shared highlight element with measured coordinates, and introduces a subtle accessibility bug: while the highlight slides, the item’s actual focus ring was suppressed to “avoid double indicators”, so for 200ms there is nothing to tell a keyboard user where focus is.

The visual effect is worth having. The rule it must obey is that the real focus indicator is not part of the animation.

Root cause analysis: decoration and indication are different jobs

A focus indicator must be immediate and unambiguous. WCAG requires a visible focus indicator, and moving focus must be perceivable. An indicator that fades in over 150ms, or that is represented only by a highlight still travelling from the previous item, fails users who navigate quickly — which is most keyboard users.

A shared highlight is decoration. It shows where focus is in a pleasing way, but the element’s own :focus-visible outline is what guarantees the requirement. Both can coexist: draw the outline instantly, and let the decorative highlight glide behind it.

Anchoring removes the measuring. anchor-name applied through :focus-visible — or through a state attribute — names the current item. A single absolutely positioned highlight anchored to it takes its insets from anchor(), and transitions on those insets make it glide, the same mechanism as sliding tab indicators.

The first appearance should not slide. When nothing was focused before, the anchor appears from nothing, and a transition from the initial position produces a slide from the top-left corner. Fading the highlight in on first focus and enabling the slide afterwards avoids that.

Pointer and keyboard should not fight. If both :hover and :focus-visible set the anchor name, moving the mouse while arrowing makes the highlight jump between the two. Pick one source of truth — usually keyboard focus — and give hover a separate, cheaper treatment.

Two indicators, two responsibilitiesThe decorative highlight may animate; the focus indicator may not.Two indicators, two responsibilitiesPurposeTimingMay animate?:focus-visibleoutlineMeets the focus requirementInstantNoShared anchoredhighlightDecorative continuityGlidesYesItem backgroundon hoverPointer affordanceShort fadeYes
The decorative highlight may animate; the focus indicator may not.

Step-by-step resolution

A gliding highlight that keeps focus visibleOutline first, decoration second.A gliding highlight that keeps focus visible1Give items a strong :focus-visible outline with no transition.Focus is always instantly visible2Set anchor-name on the item matching :focus-visible.The anchor follows keyboard focus3Position one shared highlight with anchor() insets behind the items.A single decorative element4Transition the insets with the move token.The highlight glides5Fade the highlight in when focus first enters the list.No slide from the corner6Under reduced motion, place the highlight without gliding.
Outline first, decoration second.

Production code pattern

.nav { position: relative; }

.nav a:focus-visible {
  outline: 2px solid var(--color-primary-700);
  outline-offset: 2px;                            /* instant, never transitioned */
  anchor-name: --focus-item;
}

.nav__highlight {
  position: absolute;
  position-anchor: --focus-item;
  left: anchor(left);
  top: anchor(top);
  width: anchor-size(width);
  height: anchor-size(height);
  z-index: -1;                                    /* behind the link text */
  border-radius: 10px;
  background: var(--color-primary-100);
  opacity: 0;
  transition: opacity var(--duration-100) linear;
}

.nav:focus-within .nav__highlight {
  opacity: 1;
  transition:
    opacity var(--duration-100) linear,
    left var(--motion-move-duration) var(--motion-move-easing),
    top var(--motion-move-duration) var(--motion-move-easing),
    width var(--motion-move-duration) var(--motion-move-easing),
    height var(--motion-move-duration) var(--motion-move-easing);
}

/* Pointer affordance stays separate and local. */
.nav a:hover { background: var(--color-surface-2); transition: background-color var(--duration-100) linear; }

@media (prefers-reduced-motion: reduce) {
  .nav:focus-within .nav__highlight { transition: opacity var(--duration-100) linear; }
}

Rendering Impact: layout per frame while the highlight glides, since insets animate; paint for its background. One small element is involved. The focus outline itself is painted immediately with no animation.

Putting the glide transitions only on the :focus-within rule means the first appearance uses the base rule, which transitions opacity alone — so the highlight fades in where focus landed instead of sliding in from the corner.

Where the focus indicator comes fromThe difference is invisible until someone arrows quickly.Where the focus indicator comes fromHighlight replaces the outlineOutline suppressed to avoid duplicationIndicator is mid-glide for 200 msFast arrowing leaves focus ambiguousFails the focus requirementOutline plus decorative highlightOutline appears instantly on the new itemHighlight glides behind itFast arrowing still shows focusDecorative motion, solid indication
The difference is invisible until someone arrows quickly.

Rapid keyboard traversal

Holding the Down arrow moves focus several times a second. Each move retargets the highlight’s transitions from their current position, so it chases focus and settles when the user stops — which is the desired behaviour and requires no throttling. The outline, meanwhile, appears immediately on each item, so the current position is never in doubt. If the list is long and scrolls, the anchored highlight follows the item as the list scrolls it into view. Keep the glide duration short — around 150 to 200ms — so the highlight is never more than a step behind, following the guidance in accessible focus-ring animation patterns.

Verification checklist

Constraints and trade-offs

  • Animating insets is layout per frame; keep it to one highlight element.
  • z-index: -1 requires the items to have a background, or the highlight can disappear behind an ancestor’s background.
  • Where anchor positioning is unsupported, the highlight needs measured fallback positioning or should be omitted.
  • A highlight behind text must keep sufficient contrast for the text in both themes.
  • Focus-within based visibility keeps the highlight visible while focus is anywhere in the list, including on a disabled control.

Frequently asked questions

Can a sliding highlight replace focus outlines?

No. The focus indicator must be immediately visible on the newly focused element. A gliding highlight is decoration that can accompany it but not replace it.

How do I stop the highlight sliding in from the corner on first focus?

Put the position transitions on a rule that only applies once focus is inside the list, so the first appearance only fades in.

Should hover move the same highlight?

Usually not. Use a separate, local hover style; sharing one highlight makes it jump when the pointer and keyboard disagree.

Is animating insets a performance problem?

For one small element it is negligible layout work per frame. Do not apply the same pattern to many elements at once.