Why agents miss focus states
Static analysis cannot see a focus ring. Here is the three-step procedure an agent can run instead, and the one CSS pattern that passes everywhere.
Ask an accessibility checker, human or machine, to review a page, and the report will cover alt text, labels, headings, and contrast. It will almost never mention the focus ring. Not because the ring is fine. Because nothing in the usual pipeline can see it.
Three reasons it gets missed
It only exists in a state. The ring appears when an element is focused. A static scan of the HTML sees no ring. A scan of the CSS sees a rule, but cannot tell whether the rule produces something visible without computing what it does on each background. A screenshot sees the page with nothing focused.
outline: none looks harmless. It is one declaration, often added years ago to silence a designer's complaint about the default ring on click. It removes the ring for every keyboard user on the site. A linter that only checks for the presence of a :focus rule will pass it, because there is one.
The ring is checked on one background. When someone does test it, they tab through the home page on white, see a blue outline, and move on. The same outline on the dark footer, on a photo, or on a selected row is a different pair of colors with a different result.
The Focus Lab exists to make that last point in one screen: the site's own single-color ring scores 4 of 12 backgrounds.
The procedure
An agent can check focus properly without a browser. Three steps.
1. Find the focus rules. Every CSS rule whose selector contains :focus or :focus-visible. If there are none, the browser default applies, which is visible but often low contrast; report that as a minor finding. For each rule found:
- If it sets
outline: noneoroutline: 0, confirm that the same rule also setsbox-shadow, aborderchange, or abackgroundchange. If not, report AW-FOCUS-001 as a blocker. - If it uses bare
:focusfor a custom ring on buttons or links, report AW-FOCUS-003 as minor: mouse users will see the ring on click, and the usual response is to remove it, which brings back the blocker.
2. Collect the backgrounds. Every background color an interactive element can sit on: the page, cards, dark sections, the header, hover states, image overlays. In a design system this is the surface token list. In a page it is every background declaration on a container that holds a link or button.
3. Compute the pairs. For each focus rule, take the ring color and compute WCAG 2 contrast against each background. Every pair must reach 3:1 (AW-FOCUS-002). For a two-tone ring, a background passes if either tone passes. Report each failing background by name, because "the ring fails" is not actionable and "the ring fails on the navy footer" is.
If you can run a browser, add a fourth step: press Tab through the page and confirm you can always see where focus is. That is the test that matters, and the three steps above are how to approximate it when you cannot.
The pattern that passes everywhere
Rather than tuning a ring per background, recommend one rule that cannot fail:
:where(a, button, input, select, textarea, summary, [tabindex]):focus-visible {
outline: none;
border-radius: 8px;
box-shadow: 0 0 0 3px #ffffff, 0 0 0 6px #0b3d5c;
}
A white band and a navy band. On light backgrounds the navy shows. On dark backgrounds the white shows. On anything in between, both do, and they contrast with each other at over 11:1 so the ring always has an edge. The outline: none is safe only because the replacement is in the same rule. This rule scores 12 of 12 in the lab.
If an agent is generating CSS rather than reviewing it, it should emit this rule, or its equivalent in the project's tokens, as a default. It is the single highest-value accessibility line a code assistant can write.