Designing visible focus styles
How to design focus indicators that meet WCAG 2.4.7, when to use focus-visible, outline versus box-shadow rings, focus contrast, and design system tokens.
5 min read
Someone moving through a swap form with the Tab key has exactly one clue about where they are: the focus indicator. If it's missing, faint or hidden behind a sticky bar, they can't tell whether the next Enter press will select a token, hit "Max" or confirm the transaction.
This guide covers what WCAG requires of focus indicators, how :focus-visible changed the old "remove the ugly outline" argument, when to draw rings with outline versus box-shadow, and how to make one focus style work across a whole design system.
What WCAG asks for
Four criteria touch focus appearance:
| Criterion | Level | Requirement |
|---|---|---|
| 2.4.7 Focus Visible | AA | Keyboard-operable interfaces have a mode where the focus indicator is visible |
| 1.4.11 Non-text Contrast | AA | Visual information that identifies a component's state, focus included, has 3:1 against adjacent colors |
| 2.4.11 Focus Not Obscured (Minimum) | AA | The focused element isn't entirely hidden by author-created content |
| 2.4.13 Focus Appearance | AAA | The indicator is at least as large as a 2 CSS pixel thick perimeter of the component, and its pixels change by at least 3:1 between unfocused and focused states |
2.4.7 on its own is a low bar: any visible change counts. Read together with 1.4.11, it means the indicator must also be distinguishable. 2.4.13 is AAA, but it's the most concrete spec available, and a 2px solid ring with good contrast satisfies it in most layouts. It makes a good internal standard even if you only claim AA.
:focus versus :focus-visible
The longstanding complaint about focus rings is that they appear when people click with a mouse. :focus-visible solves that. It matches when the browser decides a focus indicator is helpful: after keyboard navigation, and for text fields even when clicked, since the user is about to type. A mouse click on a button usually doesn't trigger it.
All current major browsers support :focus-visible, and their default focus rings already use it. That leaves no reason for the old habit of removing outlines, which hurts keyboard users:
/* removes focus for everyone, keyboard users included */
button:focus {
outline: none;
}
Style :focus-visible instead, and leave plain :focus alone unless you have a specific reason:
:root {
--focus-ring-color: #a3a3a3;
--focus-ring-width: 2px;
--focus-ring-offset: 2px;
}
:focus-visible {
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: var(--focus-ring-offset);
}
One case needs thought. When a script moves focus, such as into a wallet connection modal after a click, browsers use heuristics to decide whether to show the ring, and results can differ. Test your modals with both keyboard and mouse, and make sure the initially focused element is obvious either way.
Outline or box-shadow?
Both can draw a ring around an element without changing its size. They behave differently in ways that matter.
outline |
box-shadow |
|
|---|---|---|
| Affects layout | No | No |
Follows border-radius |
Yes, in current browsers | Yes |
| Several rings at once | One outline per element | Any number, comma-separated |
| Windows forced colors mode | Kept and recolored with a system color | Removed |
Clipped by overflow: hidden on an ancestor |
Yes | Yes |
The forced colors row decides most cases. People who use Windows contrast themes see your page with author colors replaced, and box shadows disappear entirely. A focus style built only from box-shadow becomes no focus style at all. If you need a shadow for its look, pair it with a transparent outline. The outline is invisible normally, but in forced colors mode the browser gives it a system color and it appears where the shadow vanished:
.token-card:focus-visible {
box-shadow: 0 0 0 3px var(--focus-ring-color);
outline: 2px solid transparent;
}
Clipping catches people in grids and scroll containers. Tiles inside a wrapper with overflow: hidden or overflow: clip lose the outer half of their ring. Draw the ring inside the element there:
.icon-grid button:focus-visible {
outline-offset: -2px;
}
Contrast across surfaces
A single ring color has to stand out against everything focusable elements sit on. On a dark interface, #A3A3A3 on a #0A0A0A page is 7.85:1. On white, the same gray is only 2.52:1 and fails 1.4.11. Dark products often have a few light surfaces, such as white primary buttons or light tiles behind dark logos, so check those specifically.
Two techniques cover most cases.
Offset the ring. With outline-offset: 2px, a band of page background separates the ring from the element, so the ring's adjacent color is the page on both sides. A light ring around a white button on a dark page passes, because the ring touches dark page color, not the button.
Use a two-tone ring. When elements sit on both dark and light backgrounds, combine a dark inner ring with a light outer one. One of the two always contrasts with what's behind it.
.surface-mixed :focus-visible {
outline: 2px solid #0a0a0a;
outline-offset: 0;
box-shadow: 0 0 0 4px #f5f5f5;
}
The outline paints on top of the shadow, so you see 2px of dark against the element and 2px of light outside it. In forced colors mode the shadow drops away and the outline remains.
Avoid indicators that rely on a subtle color shift alone, such as a button background going from #1C1C1C to #222222. That rarely reaches 3:1 and is easy to miss. The non-text contrast guide explains how adjacent colors are measured.
Making it a design system rule
Focus styles stay consistent only if components don't each invent their own. Keep the ring in tokens, apply it with one shared rule, and let components override only the offset when they must, as in the clipped grid above.
Composite widgets need extra care:
- Roving tabindex (one item in a group is tabbable and arrow keys move between items) works with the shared rule, since real DOM focus moves to each item.
aria-activedescendant(focus stays on a container, often a combobox input, while an attribute points at the active option) doesn't.:focus-visiblematches the input, not the highlighted token in the list, so you must style the active option yourself, with the same ring tokens. This is easy to miss in token selectors built as comboboxes. Keyboard navigation in icon grids compares both approaches.
A ring that is visible but hidden behind a sticky header still fails people, and WCAG 2.2 addresses that separately; see focus not obscured.
Checking your focus styles
- Tab through the main flows without a mouse: connect wallet, open the token selector, type an amount, press "Max", review, confirm, close the modal. The ring should be visible at every step. The wallet connection modal guide covers focus handling inside that modal.
- Measure the ring against every surface it touches, including colored buttons and light tiles.
- Turn on forced colors emulation (Chromium devtools offer it in the Rendering panel) or a Windows contrast theme and repeat step 1.
- Check clipped containers, horizontally scrolling tables and grids for half-cut rings.
- Click buttons with a mouse to confirm the ring doesn't appear where you don't want it, and click into text fields to confirm it does.