vs :focus: Why It Replaced the Focus-Ring Polyfill

For years the standard advice — "never remove the focus outline" — collided with a real design grievance: mouse users saw a ring snap around every button they clicked, and designers responded by stripping outline: none site-wide, quietly breaking keyboard navigation. The narrow problem this guide solves is showing a focus indicator only to people who need it (keyboard and other non-pointer users) without hiding it from anyone, and doing so with the native :focus-visible pseudo-class rather than the JavaScript focus-ring polyfill that used to be required. This page belongs to Hover & Focus State Design, and it builds directly on the focus-indicator work in Creating Accessible Focus Indicators.

What this page settles:

  • Exactly how the :focus-visible heuristic differs from :focus
  • Why the WICG focus-ring polyfill is now obsolete
  • A modern fallback that degrades safely on legacy engines
  • How to never trip WCAG 2.4.7 while pleasing designers

The old problem and the polyfill that filled the gap

Before :focus-visible, CSS had only :focus, which fires for any focus regardless of how it arrived. Click a button with a mouse and :focus matches; tab to it with a keyboard and :focus matches identically. Designers who disliked the click-ring removed it globally — and removed it for keyboard users too, the exact people the ring exists for. The community's answer was the WICG focus-visible polyfill: a script that watched input modality, decided whether the most recent interaction was keyboard-like, and toggled a .focus-visible class you could style. It worked, but it was JavaScript on the critical path solving a presentation problem, with a flash of unstyled focus before the script booted.

The platform absorbed that heuristic into the engine. :focus-visible is a pseudo-class the browser matches using the same modality reasoning the polyfill performed, but natively, synchronously, and with no script to load. The diagram below contrasts the two input paths and shows where :focus-visible chooses to match.

Input modality routing to focus-visible A pointer click focuses without matching focus-visible, while keyboard focus matches focus-visible and shows a ring. When does :focus-visible match? mouse / touch click pointer Tab / arrow keys keyboard engine heuristic :focus matches no ring not :focus-visible ring shown :focus-visible

What the heuristic actually checks

The specification deliberately leaves the rule to the user agent, but the implemented behaviour across engines is consistent enough to design against. Roughly, the browser tracks whether the most recent interaction was keyboard-like and combines that with the nature of the element:

Situation:focus:focus-visible
Tab or arrow-key navigation to a buttonmatchesmatches
Mouse or touch press on a buttonmatchesdoes not match
Typing into a text input, however it was focusedmatchesmatches
element.focus() called after a keyboard actionmatchesmatches
element.focus() called after a mouse actionmatchesusually matches
Focus restored when a dialog closesmatchesfollows the interaction that opened it

The text-input row is the one that surprises people most. A field that accepts text always matches :focus-visible, even on a click, because the caret alone is a weak indicator of which field is live and the user is about to type blind if they are wrong. That is a deliberate exception, not a bug in your CSS.

The programmatic-focus rows explain the other common complaint. When script moves focus, the engine has no direct evidence about intent, so it falls back on the modality of the last interaction and errs toward showing the ring. Showing a ring nobody needed is a cosmetic problem; hiding one somebody needed is an accessibility failure, so the heuristic is tuned to fail in the harmless direction. Design your components on that assumption rather than trying to defeat it.


Complete working implementation

The pattern has three layers: keep a ring for engines that only know :focus, remove the click-ring where the newer pseudo-class exists, and paint the real ring on :focus-visible. Order matters, and so does the fact that layer two is written as a selector an old engine cannot parse.

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>focus-visible ring</title>
  <style>
    :root {
      --ring-color: #2d5bff;
      --ring-width: 3px;
      --ring-halo: #fff;
    }

    body { font: 16px/1.5 system-ui, sans-serif; padding: 3rem; }
    .bar { display: flex; gap: 1rem; align-items: center; }
    .btn {
      font: inherit;
      padding: 0.6rem 1.1rem;
      border: 1px solid #cbd5e1;
      border-radius: 8px;
      background: #f8fafc;
      cursor: pointer;
    }

    /* 1. Fallback for engines without :focus-visible — keep SOME ring so we
          never ship an outline-less keyboard experience to old browsers. */
    .btn:focus {
      outline: var(--ring-width) solid var(--ring-color);
      outline-offset: 2px;
    }

    /* 2. In engines that DO support :focus-visible, remove the ring for the
          cases the heuristic says don't need it (e.g. mouse clicks). An old
          engine cannot parse :focus-visible, discards this whole rule, and
          therefore keeps the ring from step 1. */
    .btn:focus:not(:focus-visible) {
      outline: none;
    }

    /* 3. ...and apply the strong ring only when the heuristic asks for it.
          outline follows border-radius in current engines, so the ring hugs
          the button rather than boxing it. */
    .btn:focus-visible {
      outline: var(--ring-width) solid var(--ring-color);
      outline-offset: 2px;
      /* A second, outer halo guarantees contrast against any background,
         including a background that happens to match --ring-color. */
      box-shadow: 0 0 0 calc(var(--ring-width) + 2px) var(--ring-halo);
    }

    /* Text inputs benefit from a visible ring even on click, which the
       heuristic already grants — no special-casing needed. */
    .field {
      font: inherit;
      padding: 0.55rem 0.7rem;
      border: 1px solid #cbd5e1;
      border-radius: 8px;
    }
    .field:focus-visible {
      outline: var(--ring-width) solid var(--ring-color);
      outline-offset: 1px;
    }
  </style>
</head>
<body>
  <nav class="bar">
    <button type="button" class="btn">Save</button>
    <a class="btn" href="#next">Continue</a>
    <input class="field" type="text" aria-label="Search">
  </nav>
</body>
</html>

The technique that makes it work

The load-bearing selector is :focus:not(:focus-visible). It means "focused, but not in a way the engine considers worth indicating" — precisely the mouse-click case designers object to. Removing the outline only inside that narrow selector is what lets you delete the click-ring without ever touching the keyboard ring.

What makes it safe is a parsing rule rather than a matching rule. CSS requires a browser to discard any selector containing a component it does not understand, and — crucially — :not() accepts a full selector list whose invalidity propagates outward. An engine from before 2021 sees :focus-visible inside the :not(), cannot resolve it, and throws away the entire rule, leaving the step-one ring intact. A modern engine parses it fine and applies the cleanup. One stylesheet, correct on both classes of engine, with no feature query and no script. This is the whole reason the polyfill became unnecessary: the modality detection it computed in JavaScript is now declarative, and forward-compatible parsing supplies the fallback for free.


Variation: high-contrast and forced-colors

Some users run a forced-colors mode such as Windows High Contrast. In that mode the system overrides your colours and drops box-shadow entirely, so a ring built only from a shadow disappears exactly where visibility matters most. Keep outline as the primary indicator and let the system theme it:

@media (forced-colors: active) {
  .btn:focus-visible {
    /* Use the system's highlight colour; box-shadow is ignored here. */
    outline: var(--ring-width) solid Highlight;
    box-shadow: none;
  }
}

For dark themes, swap the halo colour so the outer ring stays visible against the page rather than vanishing into it:

@media (prefers-color-scheme: dark) {
  :root { --ring-halo: #11151c; }
}

One further refinement is worth having in a design system: keep the ring's own appearance instant. Transitioning outline-offset or outline-color delays the moment a keyboard user can see where they are, and under fast Tab-holding it can leave the ring perpetually mid-animation. If you want motion, animate a pseudo-element halo behind the outline and leave the outline itself static — the same pre-painted-layer discipline used in smooth hover effects without JavaScript.

Browser support

:focus-visible is supported natively in Chrome 86+, Edge 86+, Firefox 85+, and Safari 15.4+, which has covered all evergreen browsers since 2021 or 2022. The :focus fallback in the implementation handles anything older. Because the cleanup selector :focus:not(:focus-visible) is parse-discarded by unsupporting engines, you do not need an @supports selector(:focus-visible) wrapper — the cascade handles the fallback for you. If you would rather be explicit, @supports selector(:focus-visible) is available in Chrome and Edge 83+, Firefox 69+ and Safari 14.1+, which is a wider set than the pseudo-class itself and therefore a valid guard.

FAQ

What is the difference between :focus and :focus-visible?:focus matches any focused element regardless of input device, so it shows a ring even on mouse clicks. :focus-visible matches only when the browser heuristic decides a visible indicator is useful, which is essentially keyboard and other non-pointer focus.

Do I still need the focus-visible polyfill in 2026? No. Native :focus-visible is supported in every evergreen browser since 2021, so the WICG focus-visible JavaScript polyfill is obsolete. Use the native pseudo-class with a plain :focus fallback for very old engines.

Should I ever remove the focus outline entirely? Never remove a focus indicator without replacing it with an equally visible one. Removing :focus outlines breaks keyboard navigation and fails WCAG 2.4.7. Scope your custom ring to :focus-visible instead.

Why does :focus-visible sometimes show a ring after a mouse click? The heuristic shows the ring when the element was focused programmatically or is a text input, since those benefit from a visible caret context. For ordinary buttons, a mouse click suppresses the ring.

Does :focus-visible work on a div with tabindex? Yes. Any element in the tab order participates, including a div with tabindex="0". If you focus a non-interactive element programmatically the heuristic usually shows the ring, because the engine cannot infer that the user knows where focus went.

Related articles

More pages in the same section.