core accessibility principles for modern frameworks

Accessible Focus Indicators in Design Systems

The focus ring is the only thing telling a keyboard user where they are. It is also the first thing removed in most design systems, because outline: none makes mouse-driven mockups look tidier. What replaces it — if anything — is usually a per-component afterthought that fails on dark backgrounds, gets clipped by overflow: hidden, or disappears entirely on the one control that needed it most.

This guide covers a single, system-wide focus indicator: how to define it, how to make it visible on every surface, and how to verify it. It extends Accessible Color Contrast & Theming.

WCAG Success Criteria Addressed:

  • 2.4.7 Focus Visible
  • 2.4.11 Focus Not Obscured (Minimum)
  • 2.4.13 Focus Appearance
  • 1.4.11 Non-text Contrast
What makes a focus indicator pass or fail Four buttons shown with different indicators. A two pixel outline with a two pixel offset in a high-contrast colour passes clearly. A one pixel outline in a mid grey fails the three to one contrast requirement against the surrounding surface. A colour change alone with no outline fails because it is not perceivable to many users and disappears in forced colours. A ring clipped by an ancestor with overflow hidden passes on paper and is invisible in practice. Save 2px ring, 2px offset ✓ passes Save 1px low-contrast ring ✗ under 3:1 Save background change only ✗ lost in forced colours Save ring clipped by overflow ✗ invisible in practice 2.4.13 Focus Appearance asks for an indicator at least two CSS pixels thick with a 3:1 contrast change against the unfocused state — an offset ring satisfies both, on any background.

Prerequisites

One Token, One Rule

The most reliable focus system is a single global rule that every control inherits, defined once against a token.

Implementation Guidelines:

  • Define --focus as a dedicated token, not a reuse of the brand colour — it must contrast with every surface the app renders, which the brand colour rarely does.
  • Apply it globally with :focus-visible, so pointer users do not see rings on click while keyboard users always do.
  • Use outline rather than box-shadow: outlines follow border-radius in modern browsers, are not clipped by overflow: hidden on ancestors, and survive forced-colors mode.
  • Add outline-offset so the ring sits clear of the control's own border, which is what gives it contrast against both the control and the page.
:root {
  --focus: #0f62fe;
}

[data-theme="dark"] {
  /* Bright enough to clear 3:1 against dark surfaces too. */
  --focus: #8ab6ef;
}

:focus-visible {
  outline: 3px solid var(--focus);
  outline-offset: 2px;
}

Three pixels is deliberate: 2.4.13 Focus Appearance asks for an indicator at least as large as a 2-pixel perimeter, and an offset outline at 3px comfortably exceeds it while remaining unobtrusive.

Never outline: none Without a Replacement

The removal is usually well-intentioned — the default ring clashes with a rounded pill button — and the replacement is usually forgotten on at least one control.

Implementation Guidelines:

  • If a component overrides the outline, it must define its own indicator in the same rule. Make that a review rule.
  • Grep the codebase for outline: none and outline: 0 periodically; every hit is either a bug or needs a comment explaining its replacement.
  • Do not rely on :focus alone in modern code — it shows rings on mouse click, which is what drove teams to remove them in the first place.
  • Keep a :focus fallback for browsers without :focus-visible support only if your support matrix needs it; the fallback should be the same ring.

Make It Visible on Every Surface

A ring that contrasts with the page background may vanish against a coloured button, a dark hero, or an image.

One ring colour against four surfaces The same focus ring drawn on four backgrounds: the page surface, a muted panel, a solid brand-coloured button, and a dark hero band. Against the first two it reads clearly. Against the solid brand button the single-colour ring is too close in tone, so a two-tone ring, a dark outline with a light inner edge, is shown as the fix that works on any background. page surface muted panel brand button needs a second tone two-tone ring ✓ works anywhere A two-tone ring — a light outline with a dark inner edge — clears 3:1 against light and dark surfaces alike, which is why it is the safest default for a design system with many background colours.

Implementation Guidelines:

  • Prefer a two-tone ring — an outline plus a contrasting inner edge — for systems with many surface colours. It cannot be defeated by a background you did not anticipate.
  • Check the ring against every --surface-* token in both themes, not just the page background.
  • Never draw the ring inside the control's padding where a background colour can swallow it; the offset is what keeps it readable.
  • In forced-colors mode, let the system draw its own indicator: outline-color: Highlight or simply avoid overriding the outline there.
/* Two-tone: works on light, dark, and brand-coloured surfaces. */
:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
  box-shadow: 0 0 0 4px var(--surface);
}

Do Not Let Sticky Chrome Cover It

2.4.11 Focus Not Obscured is about the moment after focus moves: a sticky header that covers the focused control makes the ring useless.

Implementation Guidelines:

  • Set scroll-margin-top on focusable content equal to the sticky header height, so scroll-into-view leaves the control visible.
  • Test with a keyboard walk on a scrolled page, not just at the top where nothing is sticky yet.
  • Avoid overflow: hidden on containers that hold focusable children; it clips outlines drawn outside the box.
  • Watch cookie banners and chat widgets — they are the most common cause of a permanently obscured focus indicator.
A focused control hidden behind sticky chrome A scrolled page with a sticky header at the top. Without scroll-margin, tabbing to a link near the top of the viewport scrolls it just into view underneath the header, so the focus ring is completely covered. With scroll-margin-top set to the header height, the same link lands below the header and its ring is fully visible. sticky header scrolled up under the header ✗ 2.4.11 failure sticky header scroll-margin-top clears it ✓ fully visible

How to Verify

  • Tab the whole page. Every stop must show a visible ring. If any control loses it, that control has its own outline: none.
  • Both themes. Repeat in light and dark. A ring tuned only for one theme usually fails the other.
  • Contrast measurement. Sample the ring against the adjacent surface with a contrast checker; it needs 3:1 under 1.4.11 Non-text Contrast.
  • Scrolled test. Tab through a scrolled page with sticky chrome and confirm no focused control is hidden behind it.
  • Forced colours. Enable Windows High Contrast and confirm an indicator is still drawn — if you replaced the outline with a box-shadow, it will not be.
  • Check a component in a scroll container. Focus something inside an element with overflow: auto; an outline drawn outside the box can be clipped there even when it renders fine elsewhere on the page.
  • Confirm the ring survives a theme toggle mid-focus. Focus a control, switch themes, and check that the indicator is still visible — a ring tuned to one palette sometimes vanishes against the other.

Common Accessibility Mistakes

  • outline: none with no replacement. The most common accessibility bug in any design system.
  • A ring that only contrasts with the page. Invisible on the brand button, which is the control most likely to be the primary action.
  • box-shadow instead of outline. Clipped by overflow: hidden and dropped entirely in forced-colors mode.
  • :focus instead of :focus-visible. Shows rings on mouse click, which leads someone to remove them again.
  • No offset. The ring merges with the control's border and reads as a slightly thicker edge rather than a state change.
  • Focus hidden behind sticky chrome. The indicator exists and nobody can see it.

Conclusion

A focus system is one token and one global rule, and almost every failure comes from a component quietly opting out of it. Define --focus for each theme, apply a two-tone offset outline through :focus-visible, forbid unreplaced outline: none in review, and check the ring against every surface the design system can produce — including the brand-coloured button and the dark hero. Then Tab through the page in both themes; that single pass finds nearly every remaining gap.

One organisational note: the focus indicator belongs to the design system, not to individual features. When each team invents its own ring, the product ends up with four of them — one clipped, one below contrast, one that only exists on buttons, and one that was never built. Publishing a single token and a single global rule, and reviewing any override as a design decision rather than a styling detail, is what keeps the indicator consistent as the codebase grows. It also makes the audit trivial: there is one rule to check rather than one per component.

Frequently Asked Questions

Is :focus-visible supported well enough to rely on? Yes — it is available in every current browser. The heuristic is what teams want: keyboard focus shows a ring, pointer clicks do not. If you support an older baseline, keep a :focus rule as a fallback so the ring is always drawn somewhere.

Does the ring have to be a specific colour? No. 1.4.11 requires 3:1 contrast against what is adjacent, and 2.4.13 asks for a minimum size and a 3:1 change from the unfocused state. Any colour that satisfies both is fine, which is why a dedicated --focus token beats reusing a brand colour that was chosen for other reasons.

Can I use a thicker border instead of an outline? It works visually but shifts layout unless the unfocused state already reserves the space, and it is clipped by ancestors with hidden overflow. An outline avoids both problems and is the reason the property exists.

Should custom widgets show focus on the container or the active item? On whatever holds DOM focus. With roving tabindex the active item is focused, so it gets the ring. With aria-activedescendant the container keeps focus, so you must style the virtual highlight yourself — see building an accessible combobox in React.

What about focus rings inside a canvas or a chart? Canvas has no DOM to focus, so interactive charts need real focusable elements layered over them, each with its own ring. If that is impractical, provide a keyboard-accessible alternative view of the same data rather than an unfocusable graphic.