How to fix WCAG 2.4.7 focus visible

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.”

WCAG 2.2 SC 2.4.7 (W3C)

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:

  1. The element is reachable by Tab (in DOM order — see SC 2.4.3).
  2. :focus-visible styling applies once focused.
  3. 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 using preventScroll to 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: Highlight as a fallback in @media (forced-colors: active).

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.