Focus Ring Tokens Across Themes
Permalink to "Focus Ring Tokens Across Themes"Focus indicators fail in predictable ways in data UIs: a green ring disappears on a green selected row, a dark ring vanishes in dark mode, a box-shadow ring is removed entirely by Windows contrast themes, and a 1-pixel ring is lost on dense grid lines. Each component team fixes its own focus style, and the product ends up with a dozen different rings — some invisible on some surfaces. Focus tokens, defined once and verified against every surface in every theme, solve this for the whole design system.
This guide belongs to design system tokens & component contracts.
Spec reference
Permalink to "Spec reference"- SC 2.4.7 Focus Visible (AA) — keyboard focus must be visible.
- SC 2.4.11 Focus Not Obscured (Minimum) (AA) — focused elements must not be entirely hidden by author content (sticky headers, overlays).
- SC 2.4.13 Focus Appearance (AAA) — a focus indicator of at least a 2 CSS pixel perimeter with 3:1 change in contrast.
- SC 1.4.11 Non-text Contrast (AA) — the indicator needs 3:1 against adjacent colours.
- Forced colours —
outlinesurvives;box-shadowdoes not.
When to use — and when not to
Permalink to "When to use — and when not to"Define focus tokens as soon as a design system has more than one theme or more than one surface colour. Use them in every interactive component, including chart focus highlights and grid cells.
Do not hide focus for mouse users with :focus:not(:focus-visible) tricks that also affect keyboard users in some browsers; rely on :focus-visible. Do not use colour alone to change focus state on a surface of the same hue.
The misapplication: a single brand-green focus ring, used on a selected row with a green tint, producing 1.9:1 contrast — invisible to many users exactly where they are working.
Annotated code example
Permalink to "Annotated code example"{
"focus": {
"light": { "inner": "#0d3320", "outer": "#ffffff", "width": "2px", "offset": "2px" },
"dark": { "inner": "#ffd033", "outer": "#0c0f0d", "width": "2px", "offset": "2px" }
}
}
:root { --focus-inner: #0d3320; --focus-outer: #ffffff; --focus-w: 2px; --focus-off: 2px; }
[data-theme="dark"] { --focus-inner: #ffd033; --focus-outer: #0c0f0d; }
/* Two-tone ring with outline + box-shadow halo; outline survives forced colours */
:where(button, a, [tabindex], input, select, textarea):focus-visible {
outline: var(--focus-w) solid var(--focus-inner);
outline-offset: var(--focus-off);
box-shadow: 0 0 0 calc(var(--focus-off) + var(--focus-w) + 2px) var(--focus-outer);
}
/* Dense grid cells: inset so rings are not clipped by neighbours */
.grid [role="gridcell"]:focus-visible { outline-offset: calc(-1 * var(--focus-w) - 1px); box-shadow: none; }
@media (forced-colors: active) {
:focus-visible { outline-color: CanvasText; box-shadow: none; }
}
Keyboard & AT behaviour
Permalink to "Keyboard & AT behaviour"| Surface | What to check | Typical failure |
|---|---|---|
| Grid cell | Ring visible inside the cell | Clipped by overflow or neighbours |
| Selected row | Ring distinct from selection | Same hue as selection tint |
| Sticky header area | Focus not obscured when scrolling | Header covers the focused row |
| Chart data point | Focus halo on marks | Drawn in series colour only |
| Forced colours | Outline in system colour | box-shadow ring removed |
Integration context
Permalink to "Integration context"Auditing focus contrast is covered in auditing focus indicator contrast for WCAG 2.4.11 and dense grids in designing focus indicators for dense grids. Token tests in CI follow testing dark mode contrast in CI. Charts’ focus highlights relate to focus indicators for chart data points.
Gotchas
Permalink to "Gotchas"Overflow clipping. Rings with positive offsets are clipped by overflow: hidden containers (common in grids). Use inset rings there.
Third-party components. Libraries ship their own focus styles; override them with your tokens through theme variables.
Canvas and charts. Focus drawn on canvas does not use CSS tokens; read token values in JavaScript so chart focus matches.
Worked example
Permalink to "Worked example"A product had seven different focus styles across components. Token tests found the brand-green ring failing on selected rows (1.9:1) and on the dark theme’s cards (2.3:1), and forced-colours screenshots showed box-shadow rings disappearing. The design system introduced a two-tone ring with outline plus halo, inset rings for grid cells, forced-colours rules and token tests for every surface pair. Every component switched to a shared focus utility; the next audit found no focus visibility issues.
Adoption checklist
Permalink to "Adoption checklist"- Inventory every surface colour in every theme and list surface pairs where focus can appear.
- Define two-tone focus tokens and compute contrast for each pair in CI.
- Replace component-specific focus styles with the shared utility.
- Add inset variants for dense grids and overflow containers.
- Add forced-colors rules and screenshot tests.
Testing checklist
Permalink to "Testing checklist"Design system notes
Permalink to "Design system notes"Expose focus tokens and a single focus utility (CSS mixin or class); forbid component-specific focus styles in review unless documented. Include focus states in every component’s visual tests.
FAQ
Permalink to "FAQ"Why a two-tone ring?
It guarantees contrast against both light and dark surfaces, including brand-tinted selections, without per-surface overrides.
Is box-shadow acceptable for focus?
As an addition to outline, yes. As the only indicator, no — it disappears in forced colours.
How thick should the ring be?
At least 2 CSS pixels; 2.4.13 (AAA) gives a measurable target that also makes rings robust in dense layouts.
Should focus look different from selection?
Yes. Users need to tell where they are (focus) from what they chose (selection).
How do I handle focus inside canvas charts?
Read the token values in JavaScript and draw the ring with the same colours and width.
Can we hide focus for mouse users?
:focus-visible already shows focus primarily for keyboard interaction; do not add further hiding.
What about high contrast themes in the product itself?
Provide a high-contrast theme with stronger focus tokens, in addition to supporting forced colours.
Do focus tokens need a high-contrast theme variant?
If you offer a high-contrast theme, give it thicker rings and maximum-contrast colours; forced colours support remains separate.
How do I test focus on every surface automatically?
Combine token-pair contrast tests with screenshot tests that focus a sample element on each surface in each theme.
Related
Permalink to "Related"- Auditing focus indicator contrast — measuring
- Designing focus indicators for dense grids — grids specifically
- Density tokens and target size — the other token set