Creating Accessible Focus Indicators: Building a Ring That Survives Any Background

A design system ships one focus colour. It looks excellent on the white settings page, and it is nearly invisible on the dark blue marketing hero where the same button component is reused. Nobody notices, because the people reviewing the design are using a mouse. This page is about the construction of the ring itself — the geometry, the contrast arithmetic, and the layering that makes an indicator readable wherever the component lands. It sits under Accessibility in CSS Animations, and it takes for granted that you want a custom ring; the question here is how to build one that does not fail silently.

What this page settles:

  • Why a single-colour ring cannot work everywhere
  • outline versus box-shadow versus a pseudo-element
  • The contrast target and how thickness interacts with it
  • Matching border-radius and surviving forced-colors

The failure mode, precisely

The relevant requirements are short. WCAG 2.2 SC 2.4.7 Focus Visible (Level AA) requires that a keyboard focus indicator is visible. SC 2.4.11 Focus Appearance (Level AAA) puts numbers on it: the indicator must cover at least the area of a 2 CSS pixel thick perimeter of the control, and must have a contrast ratio of at least 3:1 between its focused and unfocused states. Non-text contrast under 1.4.11 asks for the same 3:1 against adjacent colours.

The word doing the damage is adjacent. Contrast is not a property of your ring colour; it is a property of the pair (ring, whatever it touches). A #005fcc ring against #ffffff is comfortably above 3:1. The same ring against a #1e3a8a hero is not. Both are the same component, the same stylesheet, the same declaration — and one of them is a conformance failure that no static audit of your token file will catch, because the failure lives in the composition, not the token.

There are three ways out. You can forbid reuse across surfaces, which is not a real option. You can make the ring colour context-aware, which means every surface has to remember to override it and one forgotten override reintroduces the bug. Or you can build a ring that carries its own contrast — two concentric tones, one light and one dark, so that whichever background it lands on, one of the two edges is readable. The third is the only one that fails safe.

Two-tone focus ring anatomy The left panel shows a control wrapped by an offset gap, an inner tone and an outer tone; the right panel explains why two adjacent tones guarantee contrast on any background. A ring that carries its own contrast Ring anatomy Button outline + box-shadow offset gap between them Why two tones light tone reads on dark dark tone reads on light Any background fails at most one tone, so an edge always clears the 3:1 target. Thickness and offset are part of perceptibility, not just colour.

Approach rationale: three ways to draw a ring

outline is the primary tool. It is painted outside the border box, so it never participates in layout and cannot shift neighbouring content; outline-offset moves it away from the element without changing geometry; and it follows border-radius automatically in current browsers, which was the historical reason people abandoned it. It is also the only one of the three that forced-colors mode reliably preserves. Its one real limitation is that it is a single stroke — you cannot express two tones with it alone.

box-shadow can produce an arbitrary number of stacked rings using a spread radius with no blur, and it follows border-radius exactly. It is the natural way to supply the second tone. Its problem is that forced-colors mode removes box shadows entirely, so a ring built only from box-shadow vanishes for the users most dependent on it. It is a good second layer and a bad only layer.

A pseudo-element is the fallback for cases the other two cannot express — a ring that must sit inside a clipping ancestor, or one whose shape differs from the element's. The cost is real: the element needs position: relative, the pseudo-element needs pointer-events: none so it does not intercept clicks, and if the element already uses ::after you are out of slots. Reach for it when clipping or shape forces your hand, not by default.

The decision is therefore not a contest. Use outline for the outer tone, box-shadow for the inner tone, and keep a pseudo-element in reserve. Which selector triggers it — :focus or :focus-visible — is a separate question covered in :focus-visible vs :focus; everything below assumes :focus-visible.

The difference is easiest to observe directly. Click each button with a mouse, then reach them with the Tab key.

Live demofocus-visible versus focus
Click both buttons, then Tab to them — only the first stays quiet under the mouse.

Complete working implementation

Three controls on two different surfaces, sharing one ring definition. The ring is never redeclared per surface.

<div class="surface surface--light">
  <button class="btn" type="button">Save draft</button>
  <a class="btn" href="/settings/">Settings</a>
</div>

<div class="surface surface--dark">
  <button class="btn" type="button">Save draft</button>
</div>
:root {
  /* Two tones, chosen so that each clears 3:1 against the other and
     against the extremes of the surfaces we ship. */
  --ring-dark: #0b1220;
  --ring-light: #ffffff;
  --ring-width: 3px;
  --ring-offset: 2px;
}

.btn {
  /* border-radius is declared on the element, and both the outline and
     the box-shadow follow it, so the ring never has square corners on a
     rounded control. */
  border-radius: 0.375rem;
  border: 1px solid currentColor;
  padding: 0.5rem 1rem;
  background: transparent;
  color: inherit;
  font: inherit;
  cursor: pointer;
}

.btn:focus-visible {
  /* Outer tone: outline sits outside the border box and is preserved in
     forced-colors mode, so this is the layer that must never be absent. */
  outline: var(--ring-width) solid var(--ring-light);
  outline-offset: var(--ring-offset);

  /* Inner tone: a spread-only box-shadow, no blur, so its edge is crisp.
     The spread equals the offset plus the width, which places this ring
     immediately inside the outline with no gap between the two tones —
     that adjacency is what guarantees a readable edge. */
  box-shadow: 0 0 0 calc(var(--ring-offset) + var(--ring-width)) var(--ring-dark);
}

/* Surfaces. The ring is not redeclared here; only the surface changes. */
.surface { padding: 1.5rem; }
.surface--light { background: #f8fafc; color: #0b1220; }
.surface--dark  { background: #1e3a8a; color: #f8fafc; }

/* Forced-colors mode replaces author colours with a small system palette
   and drops box-shadow entirely. The outline survives, so restore the
   ring using a system colour keyword and let the second tone go. */
@media (forced-colors: active) {
  .btn:focus-visible {
    outline: 3px solid Highlight;
    outline-offset: 2px;
    box-shadow: none;
  }
}

/* If the ring animates at all, the movement is optional and the colour
   change is not — so only the transition is gated. */
@media (prefers-reduced-motion: no-preference) {
  .btn {
    transition: outline-color 120ms ease, box-shadow 120ms ease;
  }
}

Both buttons get an identical ring. On the light surface the dark inner tone carries the contrast and the white outer tone reads as a halo; on the dark surface the roles swap. Neither placement required a surface-specific override, which is the property that makes this survivable in a large codebase.

The technique that makes it work

The load-bearing detail is calc(var(--ring-offset) + var(--ring-width)) on the shadow spread. A box-shadow spread grows the shadow outward from the border box, while outline-offset pushes the outline outward from the same edge. Setting the spread to offset-plus-width makes the shadow's outer boundary land exactly where the outline's outer boundary is, so the dark shadow fills the offset gap and sits directly beneath the light outline. The two tones are then contiguous, with no background colour showing between them.

That adjacency is the whole argument. If a gap existed, the light tone would be evaluated against the page background on both of its sides and could fail on a light surface. With the tones touching, the light stroke's inner neighbour is always the dark stroke — a fixed, known pair that clears 3:1 by construction — and only its outer neighbour is unknown. The worst case is therefore that one of the two edges is low-contrast, never both. You get contrast guarantees from geometry rather than from remembering to override a colour.

The second detail is that box-shadow is used with zero blur. A blurred shadow has no defined edge, so the "perimeter" measurement in 2.4.11 has nothing to measure and the perceived thickness is well below the declared value. Spread-only shadows behave as solid rings.

Variation: rings inside a clipping ancestor

An outline is drawn outside the border box, so any ancestor with overflow: hidden — a scrolling list, a card with a clipped image — will cut it off. Two fixes, in order of preference. Give the clipping ancestor padding at least equal to the ring width plus its offset, so the ring has room inside the clip. Where that is not possible, redraw the ring inward:

/* Inset variant: both tones render inside the element's own box, so a
   clipping ancestor has nothing to cut. Requires the control to have
   enough padding that the ring does not collide with its label. */
.list--clipped .btn:focus-visible {
  outline: var(--ring-width) solid var(--ring-dark);
  outline-offset: calc(var(--ring-width) * -1);   /* negative: draws inward */
  box-shadow: inset 0 0 0 calc(var(--ring-width) * 2) var(--ring-light);
}

A negative outline-offset pulls the outline inside the border box, and an inset shadow with double the spread places the second tone just inside it — the same contiguous two-tone structure, mirrored. The tones are swapped so the darker one is on the outside, where it now meets the control's own background rather than the page's.

Browser support

outline following border-radius is the constraint worth checking: every current engine does it, but it arrived late enough that older releases are still in circulation. Where it is missing, the outline renders as a rectangle around a rounded control — visible and conformant, just square, so it degrades acceptably rather than failing. :focus-visible is supported in Chrome 86+, Edge 86+, Firefox 85+ and Safari 15.4+, with @supports selector(:focus-visible) available if you still need a :focus fallback. forced-colors is supported in Chrome 89+, Edge 79+, Firefox 89+ and Safari 16+; the system colour keywords Highlight and CanvasText are only meaningful inside that mode. box-shadow with spread has universal support.

FAQ

Why does my focus ring disappear on some backgrounds? A single-colour ring can only contrast with the colours it happens to sit against. If the same component appears on a white card and a dark hero, one of those placements will have insufficient contrast. A two-tone ring solves it because one of its two edges always contrasts.

Should I use outline or box-shadow for a focus ring? Use outline for the ring itself. It follows border-radius in current browsers, never affects layout, and survives forced-colors mode. Use box-shadow only as the second tone of a two-tone ring, layered under the outline.

How much contrast does a focus indicator need? Aim for at least 3:1 between the indicator and the colours immediately adjacent to it on both sides, which is the ratio WCAG 2.2 uses for non-text contrast and focus appearance. Thickness matters too, since a very thin ring is hard to perceive even at a passing ratio.

How do I keep a focus ring from being clipped by overflow: hidden? An outline drawn outside the element is clipped by any ancestor with overflow: hidden. Either give the scrolling ancestor padding equal to the ring width plus its offset, or draw the ring with an inset box-shadow so it renders inside the element's own box.

Related articles

More pages in the same section.