Style a visible keyboard focus ring with custom properties
Canonical URL: https://devexamples.com/css/style-visible-keyboard-focus-with-custom-properties/
The problem
A design wants no outline flash on mouse clicks but a clearly visible indicator for keyboard users, in light and dark schemes, and inside Windows high-contrast settings. Removing outlines in a reset is the usual starting mistake, and a box-shadow ring alone disappears when the platform takes over colour.
Short answer
Declare ring width, offset and colour as tokens per scheme, then apply an outline with outline-offset on :where(interactive elements):focus-visible, plus a neutral inner halo, without ever setting outline to none on a focused control.
A focus ring has to do three jobs at once: appear only when focus is something the user cannot otherwise see, stay visible whatever colours are behind it, and survive the platform overriding your palette. Tokens make that a single declaration instead of a per-component argument.
:root {
--de-focus-ring: #0b57d0;
--de-focus-ring-inner: #ffffff;
--de-focus-ring-width: 0.1875rem;
--de-focus-ring-offset: 0.125rem;
}
@media (prefers-color-scheme: dark) {
:root {
--de-focus-ring: #8ab4f8;
--de-focus-ring-inner: #12151a;
}
}@layer components {
:where(
a[href],
button,
input:not([type="hidden"]),
select,
textarea,
summary,
[contenteditable="true"],
[tabindex]:not([tabindex="-1"])
):focus-visible {
outline: var(--de-focus-ring-width) solid var(--de-focus-ring);
outline-offset: var(--de-focus-ring-offset);
box-shadow: 0 0 0 var(--de-focus-ring-offset) var(--de-focus-ring-inner);
}
}/* A replacement indicator, never a bare removal. */
.button {
min-block-size: 1.5rem;
min-inline-size: 1.5rem;
}
@media (forced-colors: active) {
:where(a[href], button, input, select, textarea, summary, [tabindex]):focus-visible {
outline: 2px solid Highlight;
outline-offset: 2px;
box-shadow: none;
}
}Explanation
:focus-visible matches when the browser decides a focus indicator would be useful, which in practice means keyboard or scripted focus rather than a pointer press on a button. That distinction is what lets the ring stay strong without flashing on every click. The rule is written as one :where(...) selector list so it contributes zero specificity: any component may then set its own outline for a special case without a fight, and the token block is the only place the ring’s geometry is decided. Three ring colours and two measures are separated because they change for different reasons — the width and offset are design constants, while --de-focus-ring and its inner halo are scheme values that belong beside the rest of the palette. outline carries the indicator and box-shadow carries the halo: with zero blur and a spread equal to outline-offset, the shadow fills exactly the gap the outline was pushed away by, so the outer ring always meets a known neighbour. In the light scheme the ring is #0b57d0, which measures 6.39:1 against #ffffff and 5.06:1 against the field fill #e2e5ea; in the dark scheme it is #8ab4f8 at 8.68:1 against #12151a and 6.86:1 against #232a34. Every one of those clears the 3:1 that SC 1.4.11 asks of a state indicator, and the inner halo clears the same ratio against the ring itself in both schemes.
The WCAG 2.2 numbers you are actually answering go in three places. SC 2.4.7 Focus Visible is the AA requirement that a keyboard-focusable interface shows some indicator, and a bare outline: none in a reset is the classic way to fail it, which is why the third block above restores a ring rather than removing one. SC 1.4.11 Non-text Contrast is the AA rule behind the 3:1 figures above. The two new-in-2.2 criteria that this pattern touches are SC 2.4.11 Focus Not Obscured (Minimum), also AA, which fails when author content covers the focused element entirely, and SC 2.5.8 Target Size (Minimum), AA, which asks for a 24 by 24 CSS pixel target and is the reason the .button rule sets minimum block and inline sizes: a perfect ring around a 16 pixel icon button still leaves a target a keyboard or touch user struggles to hit. SC 2.4.13 Focus Appearance is the AAA follow-up that asks for a perimeter of at least 2 CSS pixels and 3:1 between focused and unfocused states, and a 0.1875rem ring at the default root size is 3 pixels, so it satisfies the AAA measure as well.
Two boundaries are worth testing before you call this done. The forced-colors: active block exists because the platform rewrites colour-bearing properties there: the outline is kept and forced to a system colour, while a shadow ring is commonly discarded, so the indicator must be an outline first and the halo only a bonus; Highlight is the system colour name that tracks the user’s own selection colour. The other is engine behaviour the pseudo-class does not smooth over: a text input that accepts keyboard input matches :focus-visible even when it was focused with a pointer, so a ring on click inside a field is intended behaviour and not a regression to fix. And because :where() contributes no specificity, a stray outline: 0 declared later in the same cascade origin silently wins over this rule, so when a component needs a different ring it should restate the width and colour tokens rather than clear the outline.
Usage notes
- Keep the ring in outline rather than box-shadow: forced-colour modes force outline-colour to a visible system colour and commonly discard shadows.
- The neutral halo token should equal the page background of its own scheme so the outer ring always meets the same neighbour.
- Set the ring width in rem so it scales with the reader's root font size instead of staying a fixed two device pixels.
Common mistakes
- Adding outline: none in a reset and planning to restore it per component, which leaves every control you forget unreachable by keyboard.
- Styling :focus instead of :focus-visible, which shows the ring on every mouse click for buttons but not for the keyboard users it was for.
- Relying on a colour change alone, such as a darker border, with no offset and no shape change at the boundary.
Caveats
- A ring drawn with outline-offset grows outward, so a control flush against a sticky header or an overflowing card can clip part of it.
- This ring only helps a control that can be reached by keyboard; a focusable target under 24 by 24 CSS pixels still fails WCAG 2.2 SC 2.5.8.
Related examples
Editorial links first, then deterministic same-task or same-topic candidates. Tags and shared language alone never qualify a candidate.
Related
Associate every form control with an explicit labelThe label supplies the name that a screen reader announces while this ring marks the control.
Same task or topic
Define colour tokens for light and dark schemesSolves the Create CSS design tokens task
Same task or topic
Build a card grid that reflows without media queriesShares the CSS Layout topic
References
- :focus-visible - CSS | MDN(opens in a new tab) — MDN Web Docs. Matches when a focus indicator is useful and records the text-input difference.
- Success Criterion 1.4.11 Non-text Contrast(opens in a new tab) — W3C. Sets the 3:1 ratio a focus indicator must clear against adjacent colours.
- Success Criterion 2.5.8 Target Size (Minimum)(opens in a new tab) — W3C. The new WCAG 2.2 target-size rule that a focus ring cannot compensate for.
Source page: https://devexamples.com/css/style-visible-keyboard-focus-with-custom-properties/