Focus
The focus indicator is the keyboard user's cursor. It is the one visual state you never get to remove — and the one place where a brand colour is not automatically allowed to be itself, because a colour chosen for brand reasons cannot promise to be visible.
The ring is your brand, at a step that can be seen
Tab through anything on this site. The ring is one 2px band, 2px clear of the element, and it is the brand — just not always the brand's fill step.
Here is the problem that shapes everything else on this page. Measured on 2026-08-16, a ring taken straight from the brand fill managed 2.95:1 against the raised surface and 2.66:1 against the sunken one — both short of the 3:1 that SC 1.4.11 requires. And that is not a bad colour choice to be corrected: whatever a spoke picks for its brand is picked for brand reasons and can land anywhere on the luminance scale. So "the ring is the brand" and "the ring always clears 3:1" cannot both be guaranteed by one flat value.
The reason the fill step in particular falls short is worth naming, because it looks like it should work: a Radix step 9 is engineered to clear 3:1 as a solid fill carrying text. A focus ring needs 3:1 as a 2px hairline against the same surfaces. Same number, harder job.
So the ring walks the brand's own ramp — step 9, then 10, 11, 12 — and takes
the first step that clears 3:1 on every surface in that scheme. The hub's own green moves one
step, from grass-9 to grass-10, and lands at 3.07:1 on the sunken
surface. Both shipped themes, beacon and qanat, already clear 3:1 at step 9 and are
untouched. A brand that works is never moved.
Swept across 216 brand seeds in both schemes, every ramp had a usable step — the worst best-available was 9.55:1. Step 12 of a light ramp is dark and step 12 of a dark ramp is light, so the far end of the curve is always usable. There is a neutral fallback underneath for a ramp with no usable step, and in practice it never fires: the ring is always the brand.
A second, non-brand contrast band — the accessibility literature's universal focus indicator, pairing a light and a dark outline so one always contrasts — was tried on 2026-08-16 and reverted the same day: it read as a heavy near-black slab around every focused control. Do not re-add a band without agreeing the requirement and the weight first. Its three tokens were never shipped.
The three tokens
| Token | Maps to | What it is |
|---|---|---|
--focus-ring-color | --color-border-default-focus | The ring. Your brand, at the step that can be seen. |
--focus-ring-width | --border-width-focus | Thickness. 2px. |
--focus-ring-offset | 2px literal | The gap between the element and the ring. A separated ring reads as its own mark rather than a thicker border. |
--color-border-default-focus is where the walk's answer lands, and it names a
brand ramp step rather than the brand role. It used to chain off
--color-background-brand, and giving that up was the price of the guarantee: a
var() chain that always resolves cannot express "the brand, unless the brand
cannot be seen". CSS has no function that tests a candidate colour's contrast — Color 5's
color-contrast() was cut down to contrast-color(), which only returns
black or white against a background — so the condition cannot live in the cascade at all.
Three things replace what the chain used to guarantee, so a spoke still cannot end up with the
hub's hue: theme-recipe.mjs emits this role for every generated
theme in both schemes, even when the walk picks step 9 and the value does not change; the spoke
template carries the declaration so a hand-authored theme has it in front of it; and
check-contrast.mjs grades it at fail against all four surfaces in both
schemes. Teeth in the gate, not the cascade.
A spoke should not declare --color-border-default-focus by hand.
A theme file loads after tokens.css at equal specificity, so declaring it overrides
the walk and hands back the failure it exists to prevent. Let
make-theme.mjs pick it.
One ground no brand-derived ring can serve. A knockout app bar is near-black
in the light scheme, so a ring dark enough for near-white surfaces is nearly invisible
on it — measured 2026-08-17, qanat 2.82:1 and the spoke template 2.54:1, neither caused nor
fixed by the walk. That is what the tier-3 hook is for: a component on dark ground re-points
--focus-ring-color locally, as esa-button variant="chrome" already
does by taking it from currentColor. The gate carries it as a
warn rather than pretending a single token could fix it.
On an invalid field the ring turns red
Ten components have an error state, and each re-points one token on its error wrapper:
.field--error {
--focus-ring-color: var(--form-error-border-color);
}
Same band, same width, same offset — only the colour moves. That is the whole rule, and
re-pointing the token rather than overriding outline-color is
deliberate: a field is not one focusable thing. esa-combobox has five things that read the ring colour — an input, a text
trigger, a field trigger, the dropdown's own search box, and every chip remove button.
esa-checkbox-group has N boxes, esa-radio-group N circles, and
esa-form-field does not own its control at all. A property override means one
rule per focusable part, and every part you miss keeps ringing brand-green inside a field
that is telling the user it is invalid. Custom properties inherit, so one
declaration reaches all of them — including slotted light-DOM content, and across a shadow
boundary, which is the only channel that works in every engine.
| Component | Error wrapper | What the ring used to be |
|---|---|---|
esa-text-field, esa-textarea, esa-input-tag | .field--error | a red box-shadow, stacked as a second band |
esa-select, esa-combobox, esa-date-picker | .field--error | a box-shadow in red-6 — 1.40:1, near-invisible |
esa-form-field | .esa-form-field--error | nothing at all — a brand ring on an invalid field |
esa-checkbox-group, esa-radio-group | .items--error | nothing at all |
esa-button-toggle | .group--error | nothing at all |
A dropdown panel inside the field inherits this too. esa-select and esa-combobox both render their panel inside
.container, so the search box in an invalid combobox's dropdown rings red as
well. That is consistent — it is all one field — but it is not something you would predict
from reading the rule, so it is written down here as a decision rather than left to be
discovered.
The property-override version broke, and that is why the token is the mechanism.
This rule was originally a box-shadow, back when the base rule painted the
ring with box-shadow too — a true override. On 2026-08-16 the forced-colors
pass converted the base rule to outline and left this one behind. The two
rules no longer shared a property, so the override became an addition: a focused
invalid field painted three concentric bands in two colours — red border,
red shadow flush to it, a 2px gap, then the brand outline. Nobody designed that.
check-a11y could not see it either, because its ring check asks whether a
ring is present, and two rings pass that as easily as one. Re-pointing the token
cannot fail that way: there is no second property to fall out of step with.
Because the ring has two colours, both are gated.
--form-error-border-color is graded against the same four surfaces at the
same 3:1, at fail level — red-9 measures 3.43:1 on the worst surface, and
red-11 under the assurance profile measures 4.57:1.
Not --color-border-utility-danger. Three of the six painted
this ring from that role until 2026-08-17, and it is red-6 — a subtle
border step, measuring 1.40:1 on a sunken surface, with a
literal fallback of rgba(211, 47, 47, 0.25) at 1.26:1. Three components
shipped an error ring that was very nearly invisible, and nothing measured it because
the ring had no gated row at all. It is still the right token for
esa-error-summary's 1px panel border, which is what a step 6 is for.
Note what the red ring is not doing: carrying the error. WCAG requires the error
be identified in text (SC 3.3.1) and forbids colour as the only signal
(SC 1.4.1), and the components already satisfy both —
aria-invalid="true" on the input, the message linked by
aria-describedby, and the visible error text. The red ring is a design
choice on top of a contract that is already met, which is why it is allowed to be a
colour change and nothing more.
Writing a focus rule
The house shape, and the only one you should need:
.thing:focus-visible {
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: var(--focus-ring-offset);
}
This sample carried a fourth line until 2026-08-17 —
box-shadow: 0 0 0 var(--focus-ring-halo-spread) var(--focus-ring-halo) — and
neither of those tokens was ever declared. A var() with nothing to resolve to and
no fallback is invalid at computed-value time, so the declaration was discarded silently, with
no console warning. Anyone who copied this shape wrote a dead line. Worth knowing as a failure
mode, not just as a correction.
-
outlinefor the ring, neverbox-shadow. Forced-colors modes replace backgrounds, borders and box-shadows with system colours; outlines survive. Fourteen components painted their ring with box-shadow alone until 2026-08-16 — every form control we ship — which meant the entire form layer had no keyboard indicator in Windows High Contrast Mode.outlinehas followedborder-radiusin every engine since 2021, so the rounded ring that motivated box-shadow is no longer a reason. -
:focus-visible, not:focus— the ring appears for keyboard and programmatic focus but not on a mouse click. Two exceptions, both deliberate: text entry uses:focus-withinon the field wrapper (a ring on click is native behaviour for something you are about to type into), andesa-error-summaryuses bare:focusbecause a user sent there by a failed submit did not click anything and:focus-visibleheuristics can decline to paint. - Never a bare
outline: none. If the ring belongs somewhere else — a chromeless input inside a bordered row — put a:focus-withinring on the row and say so in a comment. The reset on its own is a check-a11y block. - Inset the ring only when the box is clipped.
outline-offset: calc(var(--focus-ring-offset) * -1), for a full-bleed row inside anoverflow: hiddenpanel. A flush 2px inner ring has the same area as a 2px perimeter, so it still meets Focus Appearance — but an outward ring is easier to see and should be the default.
Where focus GOES — the other half
Everything above is about drawing the ring. This is about moving it, which is the half
that has no CSS in it at all and where every defect this kit has shipped actually lived.
A focus trap that is slightly wrong looks exactly like one that is right —
focus still moves, just somewhere you did not mean — and the build, the types and axe are
silent about all of it. npm run a11y stayed green through every finding below.
- A modal is a native
<dialog>opened withshowModal(). Do not hand-roll one. The platform supplies inertness, focus containment, focus return against the real trigger node, Esc including OS-level close requests, and the top layer. Until 2026-08-18 all six of ours wereposition: fixeddivs simulating that, andinertappeared nowhere in the repo — so everyaria-modalwe shipped announced a modality nothing enforced. - Do not write
aria-modalon a dialog you opened withshowModal(). ARIA in HTML forbids it, and the platform already says it. The attribute is only ever needed by the hand-rolled version that should not exist. - A NON-modal overlay must not inert anything, and must not steal focus on open. Menus, popovers, tooltips and anchored dropdowns annotate the page; making it inert behind them, or yanking the caret out of the field they describe, is the bug rather than the fix. What they owe is the reverse trip: focus back to the trigger on close, guarded on focus actually being inside — an outside click has already moved focus somewhere the user chose.
- Capture with
deepActiveElement(), neverdocument.activeElement. The latter retargets to the shadow HOST when focus is inside a shadow root, so restoring to it lands on the wrapper rather than the control the user was in — silently, because the wrapper is usually focusable enough to look like it worked. It lives in@esa/ecology/overlay, along with the kit's one focusable-element selector. - Anything reachable by Tab has to be visible when it is. A button at
opacity: 0is still focusable.esa-entity-search's row actions reveal on:hover, and the moment Tab could reach them that rule alone would have put the ring on something invisible — so they reveal on:focus-withintoo. - Do not spend Tab on something else.
esa-entity-searchbound it to cycling its facets, which left the per-row action buttons reachable by no key at all — SC 2.1.1, Level A, hiding behind a widget that looked fully keyboard-operable. Arrow keys navigate within a widget; Tab moves between them.
The top layer takes the announcer with it, and nothing in the DOM shows that.
A modal <dialog> blocks everything outside itself — from the pointer,
from focus, and from the accessibility tree. The kit's two live regions live in
document.body, outside every dialog, so in-modal announcements reached nobody
the day we went native. Measured in Chromium, Firefox and WebKit;
region.inert reads false in all three, because the IDL
attribute reflects the inert content attribute and not modal blocking. A
[popover] region promoted to the top layer was tried and does not help — the
dialog blocks everything outside itself, top layer or not.
announcer.ts now re-homes its regions into the open modal.
npm run a11y:overlays is what proves it, in a real browser; nothing static can.
What each criterion asks, and how we meet it
| Success criterion | Level | Asks for | How we meet it |
|---|---|---|---|
| 2.4.7 Focus Visible | AA | A visible indicator exists for anything that takes keyboard focus. | Every focusable element paints the ring. check-a11y blocks removal at write time — including the unconditional `.thing { outline: none }` shape that the guard missed until 2026-08-16. |
| 1.4.11 Non-Text Contrast | AA | The indicator contrasts 3:1 with adjacent colours. | The ring is the first step of the brand ramp clearing 3:1 on every surface. The hub default lands on grass step 10: 3.91:1 raised, canvas and floating, 3.07:1 sunken. The ring has a SECOND colour — red, on an invalid field — and it is held to the same bar: red-9 at 3.43:1 worst. check-contrast.mjs blocks on all four surfaces for BOTH colours, in both schemes, per theme. |
| 2.4.13 Focus Appearance | AAA | Contrasting area at least as large as a 2px perimeter of the element. | One 2px band outside the box, which meets the 2px perimeter. The inset rings are flush 2px, which matches it exactly. |
| 2.4.11 / 2.4.12 Focus Not Obscured | AA / AAA | The focused element is not hidden behind other content. | focus.css sets scroll-margin-block-start from --focus-scroll-margin. Any layout with sticky chrome must set it; the docs shell sets 76px for its 56px bar. |
axe does not evaluate focus indicators at all. npm run a11y
stayed green through every failure on this page, including the four components
that had deleted their ring outright — esa-entity-search,
esa-search-panel, esa-command-palette and esa-combobox,
each carrying an unconditional outline: none on a base class, and all four the
search input a keyboard user lands on first. It also cannot see a contrast ratio, which is why
the 3:1 guarantee above is enforced by check-contrast.mjs and not by axe. The test
is a keyboard and your eyes.
Light DOM vs shadow DOM
A stylesheet cannot cross a shadow boundary, so the ring is delivered twice from one source of truth.
- Light DOM —
@esa/tokens/focus.css, imported by the spoke template'sBaseLayout. It rings everya, button, summary, input, select, textarea, [tabindex]at zero specificity (:where()), so a component rule always wins. It also carries the:focus:not(:focus-visible)undo, which is what suppresses the browser's own ring only where ours replaces it. - Shadow DOM — each Lit component paints its own from the same three tokens
(custom properties inherit through the boundary). The shared
@esa/tokens/a11y.cssis bridged in viaa11y.tsand carries the forced-colors fallback, so a component listinga11yinstatic styleskeeps a ring even where its own colours are overridden.
Until 2026-08-16 the only correct page-level focus rule in the repo lived in the docs shell — a file spokes never receive. Every light-DOM component we ship had a tokenised ring here and the browser default everywhere else.