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 Visible2.4.11 Focus Not Obscured (Minimum)2.4.13 Focus Appearance1.4.11 Non-text Contrast
Prerequisites
- CSS custom properties for the palette, including a dark theme — see testing contrast in dark mode themes.
- A component library where a global stylesheet can reach every control.
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
--focusas 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
outlinerather thanbox-shadow: outlines follow border-radius in modern browsers, are not clipped byoverflow: hiddenon ancestors, and survive forced-colors mode. - Add
outline-offsetso 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: noneandoutline: 0periodically; every hit is either a bug or needs a comment explaining its replacement. - Do not rely on
:focusalone in modern code — it shows rings on mouse click, which is what drove teams to remove them in the first place. - Keep a
:focusfallback for browsers without:focus-visiblesupport 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.
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: Highlightor 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-topon 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: hiddenon 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.
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: nonewith 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-shadowinstead ofoutline. Clipped byoverflow: hiddenand dropped entirely in forced-colors mode.:focusinstead 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.
Related guides
- Accessible Color Contrast & Theming — the parent guide on palettes and contrast.
- Testing Contrast in Dark Mode Themes — verifying the ring in both schemes.
- Roving Tabindex for Navigation Menus — where the ring must follow the active item.
- Focus Management Strategies for SPAs — making sure focus lands somewhere the ring can be seen.