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.
Step-by-step resolution
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.
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: -1requires 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.
Related
- CSS Anchor Positioning & Motion — the parent topic
- Accessible Focus-Ring Animation Patterns — what may and may not animate on focus
- Sliding Tab Indicators with Anchor Positioning — the same mechanism for selection