The :focus-visible Pseudo-Class: Better Focus States Without the Pain
outline: none with nothing to replace it made entire products unusable for keyboard and switch-device users for years. Here's the pseudo-class that finally fixed that false choice.
Paul Radford — Full-Stack Developer & Editor. Paul is a full-stack developer and editor of template.tips. Articles here are AI-drafted and reviewed by Paul, who knows the code well enough to catch what's wrong and cut the hype.

:focus-visible is a CSS pseudo-class that lets browsers show focus rings only when a user is likely navigating by keyboard, letting you strip the default outline from mouse and touch interactions without breaking accessibility for people who tab through your interface. It replaces the old :focus plus JavaScript workaround pattern almost entirely.
Why :focus did not work well enough
For years, front-end developers had exactly one blunt instrument for styling focus: :focus. It fires on every focus event, regardless of input method. Click a button with a mouse and the browser's default focus ring appears. Tab to it with a keyboard and the same ring appears. Designers hated the visual noise on mouse clicks, so the common fix was outline: none combined with a custom :focus style, or worse, outline: none with nothing to replace it.
That second option is the one that did real damage. Removing outlines without a replacement made entire products unusable for keyboard users, screen magnifier users, and anyone navigating with switch devices. It also violated WCAG 2.4.7 Focus Visible, which requires a visible indicator of keyboard focus. Auditors and accessibility overlays flagged sites constantly for this, and rightly so.
Before native support existed, teams shipped the focus-visible polyfill (maintained by the WICG) which used heuristics in JavaScript: track whether the last interaction was a keydown, mousedown, or touchstart, then toggle a class accordingly. It worked, but it added a script dependency and a layer of indirection to something that should be a browser primitive. Native :focus-visible fixes that.
How does the browser decide when to show it?
The heuristic is not "keyboard versus mouse" in a literal sense, it is closer to "does the user's own input suggest they need a visible indicator." The CSS Selectors Level 4 spec leaves exact matching up to the user agent, but in practice:
- Keyboard navigation (Tab, Shift+Tab, arrow keys on certain widgets) reliably triggers :focus-visible.
- A mouse click on a button or link generally does not trigger it, because the browser assumes visual feedback from the click itself is sufficient.
- Programmatic element.focus() calls trigger it if the element itself expects keyboard interaction (like a text input), but not always for arbitrary clickable divs.
- Text inputs and textareas almost always show the ring on any focus method, mouse included, because sighted mouse users still benefit from seeing where their cursor landed.
That last point trips people up. If you test :focus-visible only on buttons, you will assume it never fires on click. Test it on an <input type="text"> and click into it: the ring appears. The browser is making a judgment call about the element type, not just the input device.
Basic usage that replaces the old pattern
The simplest migration path is to drop outline: none on plain :focus and instead define your focus styling under :focus-visible, while still zeroing out :focus alone.
1/* Old, unsafe pattern: removes focus indication entirely for keyboard users */2button: focus {3 outline: none;4}56/* Replacement: no visible ring on mouse click, full ring on keyboard focus */7button: focus {8 outline: none;9}1011button: focus-visible {12 outline: 2px solid #0b5fff;13 outline-offset: 2px;14}A cleaner way to write this, since :focus-visible support is now solid across evergreen browsers, is to skip suppressing :focus globally and instead rely on :focus:not(:focus-visible) to explicitly cancel the ring only when it is not needed:
1/* Cancels the ring specifically for non-keyboard focus,2 leaving : focus-visible as the single source of truth */3button: focus:not(:focus-visible) {4 outline: none;5}67button: focus-visible {8 outline: 2px solid #0b5fff;9 outline-offset: 2px;10}Either approach works. The second is slightly more defensive because it does not blanket-disable :focus, so if a browser or assistive technology depends on :focus styling for something you did not anticipate, it is not silently gone.
What about custom components built from divs?
Native form elements and links get sensible :focus-visible behavior for free. Custom widgets, like a div acting as a button with role="button" and a tabindex, need you to manage this yourself, and the heuristics get less predictable.
1<!-- Custom toggle built without a native <button> -->2<div3 class="toggle"4 role="switch"5 tabindex="0"6 aria-checked="false"7>8 Notifications9</div>1.toggle {2 /* base styles omitted */3 outline: none;4}56/* Because this is a div, not a semantic control,7 focus-visible detection is more conservative;8 test this specific element manually in each target browser */9.toggle: focus-visible {10 outline: 3px solid #0b5fff;11 outline-offset: 3px;12}My honest recommendation: do not build interactive controls out of divs if you can avoid it. Use <button>, <a href>, <input>, and other native elements first. Native elements get keyboard handling, focus behavior, and :focus-visible heuristics correct by default. Every custom widget is a small maintenance liability, and focus behavior is one of the first things that quietly breaks.
Handling the fallback for older engines
Full un-prefixed :focus-visible support landed in Chrome 86 (October 2020), Firefox 85 (January 2021), and Safari 15.4 (March 2022). If your analytics show meaningful traffic from Safari versions older than 15.4, which was still common in some enterprise and education environments through 2022 and 2023, you need a fallback strategy, not just a native rule.
The safest fallback is feature detection with @supports, paired with the WICG polyfill for genuinely old browsers:
1/* Fallback outline for browsers without : focus-visible support */2button: focus {3 outline: 2px solid #0b5fff;4}56/* Only browsers that understand : focus-visible get the refined behavior;7 this rule is skipped entirely in older engines */8@supports selector(: focus-visible) {9 button: focus {10 outline: none;11 }1213 button: focus-visible {14 outline: 2px solid #0b5fff;15 outline-offset: 2px;16 }17}This pattern degrades gracefully: old browsers get the blunt but accessible :focus ring on every interaction, modern browsers get the refined behavior. You never end up in the broken state of an old browser silently getting no focus indicator at all, which is the actual failure mode you are trying to avoid.
A gotcha with :focus-within and nested components
:focus-visible only describes the element that currently holds focus. It says nothing about ancestors. If you have a card component that should highlight when any interactive child inside it gets keyboard focus, you need :focus-within on the parent combined with :focus-visible reasoning on the child, and these two pseudo-classes do not compose automatically.
1/* This does NOT limit the highlight to keyboard-only focus */2.card: focus-within {3 box-shadow: 0 0 0 2px #0b5fff;4}There is no native :focus-visible-within. As of 2026 it is still a WICG discussion item, not a shipped selector in any engine. If you need that exact behavior, the pragmatic option is a small JavaScript listener that toggles a class on the ancestor by checking event.target.matches(':focus-visible') inside a focusin handler, rather than waiting on a CSS feature that may never ship.
Testing this properly before you trust it
Do not assume :focus-visible is correct just because your local Chrome build renders it the way you expect. Run through this checklist on every interactive component you ship:
- Tab through the full page using only the keyboard, with the mouse untouched, and confirm every interactive element shows a visible ring.
- Click each interactive element with a mouse and confirm no distracting ring appears on buttons and links, but rings still appear on text inputs.
- Test in Safari specifically, since its heuristics for programmatic focus and certain ARIA roles have historically diverged slightly from Chromium's implementation.
- Check your actual outline color and width against the WCAG 2.4.11 Focus Appearance guidance if you are targeting WCAG 2.2, since a thin one-pixel outline in a low-contrast color can pass automated tools while still being hard to see.
- If you support any pre-2022 browser meaningfully in your analytics, load the site in an actual old Safari or Edge Legacy instance rather than trusting @supports logic in the abstract.
Run this checklist once per component library update, not once per project. Focus behavior regresses quietly whenever someone refactors a button into a styled div, or adds outline: none in a new stylesheet without realizing an existing rule already handled it. Treat it the same way you treat color contrast: a recurring audit item, not a box you check once and forget.
Related Articles

Styling Form States: Beyond :valid and :invalid
A required email field showing an error state before anyone's typed a single character isn't a bug, it's a spec quirk. Here's the pseudo-class combination that fixes the timing problem.

field-sizing: content: Auto-Sizing Form Fields Natively
A hidden clone element measuring content on every keystroke causes real layout thrashing on large forms. This property lets the browser's own layout engine handle the resizing instead.

appearance: none: Styling Native Controls Without a Reset Framework
A checkbox renders completely differently on macOS Safari versus Windows Chrome by default. appearance: none lets you target just the controls that need surgery, not a global reset.