ADR-028 — One focus indicator: a token-bound outline ring
Date: 2026-10-07
Status: accepted
Context
No token defined a focus indicator (issue #119). Each component handled focus its own way:
- Only Select styled
:focus-visible. It removed the outline and changed its border tocolor.border.selected. - TextField removed the outline on its input and relied on the same border change on
:focus. - Button and every other interactive component showed the browser’s default ring. That ring varies by browser, follows neither brand nor theme, and nobody had checked its contrast against the accent and danger fills or the dark-theme surfaces.
The Button pilot spec (ADR-027) recorded the gap in its focus-visible state.
Two treatments were considered:
- An outline ring on every focusable element, with an offset so it sits on the surface around the control.
- A border-colour change, extending Select’s approach. Accent, danger and transparent buttons have no visible border to change, so they would need a second treatment anyway. A 1px colour change is also a weak indicator.
Decision
Option 1.
- One global rule in
packages/components/src/styles/reset.css::focus-visible {outline: var(--ds-size-focus-width) solid var(--ds-color-border-focus);outline-offset: var(--ds-size-focus-offset);}:focus-visibleshows the ring for keyboard focus and not for mouse clicks on buttons. Browsers show it on text inputs either way. - Tokens:
color.border.focus:{color.brand.11}in light,{color.brand.dark.11}in dark. It is the same step astext.interactive.default, so the ring reads as “interactive” in the brand’s hue.size.focus.widthandsize.focus.offset: both{size.025}, a new 2px primitive.
- Why brand.11: the ring sits on the surface around the control, not on its fill, because of the offset. Against canvas and elevated surfaces it measures 4.60–9.24:1 across both brands and both themes. The candidates that failed:
brand.9(2.92:1 on Horizon dark) and the existingborder.selected(3.33:1 on Upskill dark, close to the 3:1 floor). - Components never remove the outline. Select and TextField no longer set
outline: none. Their focus border change stays as an extra cue alongside the ring. - Contrast is gated.
scripts/token-contrast-check.jschecksborder.focusagainst canvas and elevated at 3:1 (WCAG 1.4.11).
Exception: menu items. DropdownMenu items keep outline: none and show focus with background.overlay-hover. This is the usual menu-item pattern in the WAI-ARIA APG, and a ring with an offset would overlap neighbouring items in the tight panel.
Consequences
- Every focusable element in the library, the showcase and Storybook gets the same visible indicator, with no per-component CSS. A new component inherits it for free.
- The ring follows brand and theme through tokens, and CI checks its contrast.
- Known limitation: controls placed over media (CardVertical’s Favorite and Menu) show the ring on top of the image, so its contrast depends on the pixels underneath. This is the same problem the on-media halo solves for the glyph (ADR-024). Revisit if keyboard testing on real photos shows the ring getting lost.
- The showcase’s
PipelineDagkeeps its own:focus-visiblerule (border.selected), which overrides the global one. Aligning it is optional. - Figma:
color/border/focusand the size variables need/figma-variable-push. Focus previews in component sets (aState=Focusvariant) can then bind to them.