Home / Snippets / UI Components /
Focus ring
Accessible keyboard focus indicators using :focus-visible, outline-offset, and box-shadow — no JavaScript needed.
Quick implementation
/* 1. Default outline */
.btn:focus-visible {
outline: 2px solid oklch(0.72 0.19 265);
outline-offset: 2px;
}
/* 2. Spaced-out offset */
.btn--spaced:focus-visible {
outline: 2px solid oklch(0.72 0.19 265);
outline-offset: 5px;
}
/* 3. Rounded / pill button */
.btn--pill:focus-visible {
outline: 2px solid oklch(0.72 0.19 265);
outline-offset: 4px;
border-radius: 9999px;
}
/* 4. Double ring via box-shadow */
.btn--double:focus-visible {
outline: 2px solid oklch(0.72 0.19 265);
outline-offset: 2px;
box-shadow: 0 0 0 4px oklch(0.72 0.19 265 / 0.25);
}
/* 5. Animated pulse ring */
.btn--animated:focus-visible {
outline: none;
box-shadow:
0 0 0 2px var(--bg),
0 0 0 4px oklch(0.72 0.19 265);
animation: focus-pulse 1.5s ease-in-out infinite;
}
@keyframes focus-pulse {
0%, 100% {
box-shadow:
0 0 0 2px var(--bg),
0 0 0 4px oklch(0.72 0.19 265);
}
50% {
box-shadow:
0 0 0 2px var(--bg),
0 0 0 6px oklch(0.72 0.19 265 / 0.55);
}
}
@media (prefers-reduced-motion: reduce) {
.btn--animated:focus-visible {
animation: none;
}
}
Prompt this to your LLM
Includes role, constraints, two framework variants, and edge cases to handle.
You are a senior frontend engineer implementing accessible keyboard
focus indicators for a UI component library.
Goal: Create custom focus ring styles for buttons and links using
:focus-visible so mouse users never see the outline, but keyboard
users always get a clear, high-contrast indicator.
Technical constraints:
- Always use :focus-visible (not :focus) to target keyboard navigation
only. Mouse clicks do not trigger :focus-visible.
- Use outline and outline-offset as the primary technique — outlines
follow border-radius automatically and don't affect layout.
- For pill/fully-rounded elements, explicitly repeat border-radius on
the outline so it tracks the shape (some browsers need this hint).
- For double-ring effects, layer outline with a translucent box-shadow.
- Use oklch() for all colors — no hex or rgba values.
- Use CSS custom properties (var(--bg), var(--accent), etc.) for
theming tokens.
- Include @media (prefers-reduced-motion: reduce) that disables any
focus animation while keeping the static ring visible.
Framework variant (pick one):
A) Global CSS reset — apply :focus-visible styles to all interactive
elements (button, a, input, select, textarea) via a single rule,
then override per-component as needed.
B) React component — a FocusRing wrapper that renders its children
inside a span with the focus class applied; accept a variant prop
("default" | "offset" | "double" | "animated").
Edge cases to handle:
- Removing outline: 0 on :focus without restoring it on :focus-visible
is a WCAG 2.4.7 violation. Never suppress focus entirely.
- Input elements on dark backgrounds may need a contrasting gap between
the element and the ring — use box-shadow: 0 0 0 2px var(--bg) as
an inner spacer before the visible ring.
- Safari (before v15.4) did not support :focus-visible; use a
progressive enhancement approach and test accordingly.
- Animated rings must respect prefers-reduced-motion — the animation
should stop, but the ring itself must remain visible.
Return CSS only (or a React component if variant B is chosen).
Why :focus-visible beats :focus
For most of the web's history, developers faced a frustrating trade-off: keep the browser's default focus ring (which fires on mouse clicks and looks jarring to mouse users) or suppress it entirely with outline: none (which destroys keyboard navigation). The introduction of :focus-visible — now at 96 %+ global browser support — resolves this completely.
The browser applies :focus-visible using a heuristic: when you navigate with a keyboard, Tab key, or other non-pointer device, the pseudo-class activates. When you click with a mouse or tap on a touchscreen, it does not. The result is that keyboard users always see the indicator, and mouse users never do — without any JavaScript.
WCAG 2.4.7 (Level AA) requires that any keyboard-operable interface component has a visible focus indicator. Relying on :focus-visible — combined with a well-contrasting outline color — is the cleanest compliant implementation.
outline vs box-shadow for rounded elements
CSS outline has been the recommended approach since the spec clarified that outlines must follow the element's border-radius. All modern browsers now render a pill-shaped outline on a fully-rounded button. outline-offset creates a gap between the element's border and the ring — positive values push the ring outward, giving breathing room without affecting layout.
The limitation of outline is that it only supports a single ring. For a double-ring effect — a dark background gap followed by a colored ring — you layer box-shadow. Use an inner shadow of 0 0 0 2px var(--bg) to fake a gap, then a second shadow of 0 0 0 4px oklch(0.72 0.19 265) for the visible ring. box-shadow is clipped to the element's bounding box so it follows border-radius naturally.
One important distinction: outline does not trigger layout changes — it draws outside the element's box model and never shifts surrounding elements. box-shadow behaves the same way. Avoid using border alone for focus rings because changing border-width on focus causes layout shift.
WCAG 2.4.7 and focus visibility requirements
WCAG Success Criterion 2.4.7 (Focus Visible) at Level AA states: "Any keyboard operable user interface has a mode of operation where the keyboard focus indicator is visible." This means suppressing outline on :focus without replacing it is a direct accessibility failure — one that automated audit tools like axe and Lighthouse will flag.
WCAG 2.2 introduced the enhanced 2.4.11 (Focus Appearance, Level AA) with tighter requirements: the focus indicator must have an area of at least the perimeter of the component times 2 CSS pixels, and a contrast ratio of at least 3:1 between focused and unfocused states. A 2 px outline in oklch(0.72 0.19 265) on a dark background comfortably meets this threshold.
For animated focus rings, prefers-reduced-motion: reduce must stop the animation — but the ring itself must stay visible. Setting animation: none inside the media query while leaving the box-shadow values intact is the correct pattern. Never hide the ring in the reduced-motion query.