Accessible Focus-Ring Animation Patterns
Part of Focus & State Motion Accessibility in Accessible Motion Architecture.
The problem: a ring that is pretty but fragile
Designers love an animated focus ring — a halo that grows and fades in as focus lands. Built carelessly, that animation harms the very users the ring exists for. Transition the wrong properties and every keyboard press repaints a large region on the main thread. Suppress it wholesale under reduced-motion and a keyboard user loses their only wayfinding cue, a direct WCAG 2.4.7 failure. The goal is a ring that animates smoothly for people who want motion, appears instantly for people who do not, and is never absent for anyone. This page gives the decision steps and one production-ready block that does all three.
Root-cause analysis: why the obvious approaches misbehave
A focus indicator is triggered when the :focus-visible (or :focus) pseudo-class matches. On that frame the browser recalculates style for the element and begins any transition on the properties that changed. Three things commonly go wrong:
- Expensive properties.
outline,outline-offset,box-shadow, andborder-widthare the intuitive way to draw a ring, but transitioning them costs paint (all four) or layout (border-width). Repainting a spread shadow on every frame is one of the more expensive things you can ask the main thread to do during input. - Size changes trigger layout. If the ring is part of the element’s box — a growing border or a widening outline offset that the layout accounts for — the control’s geometry changes and the browser runs layout on the focus frame, delaying the very feedback the user is waiting on.
- All-or-nothing reduced-motion handling. The common global reset
* { transition: none }combined with a designer’s:focus { outline: none }removes both the animation and the indicator. The animation should go; the indicator must stay.
The fix addresses all three: draw the ring on a pseudo-element positioned with negative inset (so it sits outside the box without being part of it), animate only transform and opacity (compositor-safe, no layout, no paint of the box), and in the reduced-motion branch remove the transition while explicitly asserting the ring’s visible end state.
Decision steps
Work top to bottom; each step resolves one of the failure modes above.
- Is a visible focus indicator present in every state? If any control has
outline: nonewith no replacement, stop and add an indicator first. Nothing below matters until the ring exists. - Is the ring drawn where it can animate cheaply? Move it to a
::afterpseudo-element with negativeinsetso scaling and fading it never changes the control’s size. - Which pseudo-class drives it? Use
:focus-visiblefor the animated ring so mouse clicks stay quiet, with a:focusfallback for browsers lacking support. - Which properties animate? Only
opacity(fade) andtransform: scale()(grow). Nothing else. - What happens under reduced-motion? Set
transition: noneand pin the ring to its visible end state (opacity: 1; transform: none). The indicator appears instantly and remains fully visible. - Does contrast pass? Confirm the ring colour has at least a 3:1 contrast ratio against both the control and the adjacent background (WCAG 2.4.11 / 1.4.11).
Production code pattern
The block below is copy-ready. Every compositing- and accessibility-critical decision is marked in the comments.
/* Intent: an animated :focus-visible ring that is compositor-safe,
respects reduced-motion, and never removes the visible indicator. */
.control {
position: relative; /* anchor for the ring pseudo-element */
}
/* The ring lives on ::after and sits OUTSIDE the box via negative inset,
so scaling/fading it never changes the control's size (no layout). */
.control::after {
content: "";
position: absolute;
inset: -4px; /* ring floats just outside the control */
border-radius: inherit;
outline: 2px solid #7c3aed; /* 3:1+ contrast; the essential indicator */
outline-offset: 0; /* set once — never transitioned */
opacity: 0; /* hidden until focus */
transform: scale(0.9); /* starts slightly small, grows on focus */
transition: opacity 0.15s ease, transform 0.15s ease;
pointer-events: none;
}
/* Keyboard / assistive-technology focus: fade + grow the ring.
opacity and transform only -> stays on the compositor thread. */
.control:focus-visible::after {
opacity: 1;
transform: scale(1);
}
/* Fallback for browsers without :focus-visible — show the ring on :focus,
still animated, so no keyboard user is ever left without an indicator. */
.control:focus::after {
opacity: 1;
transform: scale(1);
}
/* Reduced motion: remove the ANIMATION, keep the RING.
The indicator is pinned to its visible end state and appears instantly. */
@media (prefers-reduced-motion: reduce) {
.control::after {
transition: none; /* no fade, no grow */
}
.control:focus-visible::after,
.control:focus::after {
opacity: 1; /* still fully visible */
transform: none; /* no scale travel */
}
}
/* Windows High Contrast / forced-colors: use a system colour so the ring
survives when author colours are overridden. */
@media (forced-colors: active) {
.control:focus-visible::after,
.control:focus::after {
outline-color: Highlight;
}
}
Rendering Impact:
opacity+transformon a pseudo-element — composite only. The staticoutlinepaints once when the ring becomes visible and is never re-painted during the transition; the control’s box never changes size, so no layout runs.
Verification checklist
Constraints and trade-offs
- A negative-
insetpseudo-element ring can be clipped by an ancestor withoverflow: hidden. If the ring is cut off, give the clipping ancestor room (padding) or move the ring inside the box with a positive inset and an inner offset. :focus-visiblesupport is broad in current browsers, but the:focusfallback is essential for older engines — never rely on:focus-visiblealone, or some keyboard users get no ring.- Animating
transform: scale()on the ring means the ring’s stroke scales too; keep the scale close to1(for example0.9→1) so the outline does not visibly thin or thicken. - The ring must meet the 3:1 non-text contrast requirement against whatever it sits on. On multi-coloured backgrounds, a two-tone ring (an inner light and outer dark stroke, or
outlineplus a contrastingbox-shadowset once, not animated) guarantees contrast on any surface. - Under
forced-colors: activeauthor colours are replaced; always provide a system-colour focus style so the indicator survives High Contrast mode.
Related
- Focus & State Motion Accessibility — the parent topic on animating interactive state without relying on motion alone
- Animating ARIA Live-Region Updates — the sibling guide on keeping announced content in sync with entry motion
- Prefers-Reduced-Motion Architecture — the global reduced-motion gate and the essential-versus-decorative decision behind keeping the ring