WCAG 2.4.7 requires that any keyboard-operable user-interface component has a visible focus indicator. The W3C normative text reads:
“Any keyboard operable user interface has a mode of operation where the keyboard focus indicator is visible.”
It is the second most-common 1.4.x/2.4.x failure on production sites, almost
always caused by a global outline: none rule in a CSS reset that nobody
remembered to revert. Keyboard users who lose the focus ring lose the cursor
— they cannot tell where Enter or Space will activate.
The failing pattern
/* in a CSS reset — usually copy-pasted from a 2014 blog post */
*:focus { outline: none; }
button { outline: none; }
/* Tailwind v3 default base */
*, ::before, ::after { /* no focus ring at all on custom components */ }
Combined with custom components that style only :hover and :active, this
makes Tab navigation invisible. A blind user with residual sight, a motor-
impaired user using switch control, or anyone using a Bluetooth keyboard with
a phone on a stand cannot operate the page.
The fix — default to :focus-visible and a strong ring
/* Reset only what you intend to replace */
:focus { outline: none; }
:focus-visible {
outline: 3px solid var(--color-focus, #0369a1);
outline-offset: 2px;
border-radius: 2px;
}
:focus-visible is a CSS pseudo-class that the browser applies only when
the user agent decides a focus indicator is needed — typically for
keyboard or assistive-tech navigation, not for mouse clicks. This solves the
“designer hates the click ring” objection without removing keyboard focus.
For Tailwind v4:
<button class="rounded bg-sky-700 px-3 py-2 text-white
focus-visible:outline-3 focus-visible:outline-offset-2
focus-visible:outline-sky-700">
Save
</button>
For React/Vue/Svelte components that wrap a focusable child, the ring should
be on the focusable element — not the wrapper. A common bug: applying
focus styles to a <div> parent of a <button> and assuming it cascades.
Custom components that are not natively focusable
Anything that relies on tabindex="0" for focusability needs an explicit
focus indicator. Three checks:
- The element is reachable by Tab (in DOM order — see SC 2.4.3).
:focus-visiblestyling applies once focused.- Activating it with Enter or Space (per the relevant ARIA pattern) does not remove focus prematurely.
Avoid tabindex="-1" on visually focusable elements unless you have a
documented reason — for example, programmatically focusing a dialog
heading on open. In that case, return focus to the trigger when the dialog
closes, using aria-modal plus a focus trap (see WAI-ARIA APG dialog pattern).
How to test
Automated — axe-core does not detect “no visible focus indicator” directly, but it surfaces structural issues that often correlate. Pa11y and Lighthouse provide partial coverage. The reliable check is a Playwright keyboard test:
import { test, expect } from "@playwright/test";
test("focus is visible on every interactive element", async ({ page }) => {
await page.goto("/");
const interactive = await page.$$(
"a[href], button, input, select, textarea, [tabindex='0']",
);
for (const el of interactive) {
await el.focus();
const outline = await el.evaluate(
(n) => getComputedStyle(n).outlineStyle,
);
expect(outline).not.toBe("none");
}
});
Manual — Tab through the page from the URL bar; do not touch the mouse. At every stop, ask: “If I were watching this on a colleague’s screen share, could I tell where the focus is?” If yes, you pass 2.4.7. If you have to squint or trace by elimination, you fail.
Screen reader smoke — while not strictly required for 2.4.7, run NVDA in browse mode and verify each element announces its name and role on Tab. A missing focus ring often co-occurs with a missing accessible name.
When this is hard
- Brand pressure to remove the ring. The standard rebuttal is to keep
:focus-visible(which doesn’t fire on click) but kill:focus. This satisfies designers who object to the ring on mouse interactions while preserving 2.4.7. - Custom focus trap libraries. Some focus-trap utilities call
element.focus({ preventScroll: true }). That’s fine; what is not fine is usingpreventScrollto hide focus moving off-screen on long pages — users can lose their cursor entirely. Always scroll-into-view if the focused element is outside the viewport. - High-contrast Windows mode. Verify your custom outline colour does not
disappear when Windows High Contrast or Forced Colors mode is active. Use
outline-color: Highlightas a fallback in@media (forced-colors: active).
Cross-links
- Reference: WCAG 2.4.7 explained on w3c.wiki.
- Adjacent SC: WCAG 2.4.3 focus order and WCAG 2.1.1 keyboard.
- Testing: How to wire axe-core into CI covers Playwright keyboard automation.
- Component patterns: WAI-ARIA Authoring Practices for canonical focus-management patterns per component type.
The single most reliable test is unplug the mouse for one minute. If the team can navigate to checkout and submit a form using only Tab, Shift+Tab, Enter, Space, and Escape, your 2.4.7 posture is sound.