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 — outline survives; box-shadow does not.
Focus ring contrast against surfaces in two themes Matrix of surfaces — page, card, row hover, selected row, chart plot — against light and dark theme focus contrast, showing where a single brand-colour ring fails. Focus ring contrast against surfaces in two themesLight themeDark themePage background7.1:15.8:1Card6.9:15.2:1Row hover5.4:14.1:1Selected row (brand tint)1.9:1 single colour2.2:1 single colourChart plot6.0:14.8:1
A single ring colour rarely works on every surface; use a two-tone ring or per-surface tokens.

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.

Single-colour ring versus two-tone ring Comparison: a single brand-colour ring fails on tinted and dark surfaces; a two-tone ring (dark inner, light outer) stays visible on light, dark and tinted surfaces. Single-colour ring versus two-tone ring✗ Single brand colourFails on brand-tinted selected rowsNeeds per-theme overridesbox-shadow implementation commonDisappears in forced colours✓ Two-tone ringInner and outer colours contrast with anysurfaceWorks across themes with one token pairoutline plus outline-offset or double outlineOutline maps to system colour

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
Verifying focus tokens Flow: list surfaces; compute contrast of inner and outer colours against each surface in each theme; check forced colours; check dense grids for clipping; check sticky elements for obscuring. Verifying focus tokensList surfacespage, card, rows, selection, chartsper themeCompute contrastinner or outer at 3:1 against surfacetoken testsForced coloursoutline remainsscreenshotsDense gridsinset rings not clippedvisual reviewSticky elementsfocus not obscuredscroll tests

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"
  1. Inventory every surface colour in every theme and list surface pairs where focus can appear.
  2. Define two-tone focus tokens and compute contrast for each pair in CI.
  3. Replace component-specific focus styles with the shared utility.
  4. Add inset variants for dense grids and overflow containers.
  5. 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.

Permalink to "Related"

← Back to Design System Tokens & Component Contracts