The Problem :focus-visible Was Built to Fix
For years, the default browser focus outline was ugly enough that teams reflexively wrote *:focus { outline: none; } and moved on. That single line is one of the most common accessibility regressions on the web — it removes the only visual signal keyboard users get for where they are on the page, with nothing put back in its place.
The tension: mouse users find a focus ring around a button they just clicked visually noisy and redundant. Keyboard users need that same ring to navigate at all. :focus alone can't distinguish between the two triggers — it fires identically whether focus arrived via Tab, a mouse click, or a programmatic .focus() call. :focus-visible exists specifically to solve that.
/* fires on every focus event, regardless of input method */
button:focus {
outline: 2px solid blue;
}
/* fires only when the browser's heuristic decides a visible indicator is warranted */
button:focus-visible {
outline: 2px solid blue;
}
How the Browser Decides
:focus-visible isn't purely "keyboard vs mouse" — it's a browser heuristic defined in the Selectors Level 4 spec that accounts for element type and interaction history. In practice, across current browser implementations:
- Keyboard navigation (
Tab, arrow keys on custom widgets) →:focus-visiblematches. - Mouse click on a button or link →
:focus-visiblegenerally does not match (browsers assume the user doesn't need a persistent ring after a deliberate click). - Mouse click on a text input →
:focus-visibletypically does match, because focus indication is still useful there regardless of input method. - Programmatic
.focus()calls → behavior follows the same heuristic as if focus had arrived via the "expected" method for that element, which varies by browser.
You don't need to special-case element types yourself — that judgment call is built into the pseudo-class. Your job is just deciding what to style.
The Standard Pattern
/* baseline: remove the default outline everywhere */
:focus {
outline: none;
}
/* replace it, but only when the browser thinks it should show */
:focus-visible {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
This is the pattern most component libraries converged on: suppress :focus, restyle :focus-visible. It gives mouse users a clean click without a lingering ring, and keyboard users a clear, deliberately-styled indicator — not the browser's default blue glow, but something that matches your design system.
.btn:focus-visible {
outline: 2px solid var(--focus-ring, #3b82f6);
outline-offset: 2px;
border-radius: 4px; /* match the element's own radius so the ring reads as intentional */
}
Pairing a subtle box-shadow ring with the outline is common for buttons with existing rounded corners or elevation — the Box Shadow Generator is useful for dialing in a focus ring's spread and color without it looking like an afterthought bolted onto the design.
Decision Point: When to Reach for Plain :focus Instead
:focus-visible is the right default for interactive controls, but it is not universally correct. Use plain :focus when:
- The element is a text input, textarea, or contenteditable region. Users benefit from confirmation of exactly where their cursor landed regardless of how they got there — mouse-click focus on a text field genuinely needs a visible indicator, and most browser heuristics already agree (
:focus-visibledoes match text inputs on click in current engines), but if you're overriding default behavior, don't suppress it for inputs. - You're building a custom widget where focus state carries semantic meaning beyond "here's where keyboard input goes” — e.g., a focused row in a data table that should look focused whether it got there by click or by arrow-key navigation, because the visual state communicates selection context, not just keyboard position.
- You need guaranteed support in an environment where
:focus-visibleisn't reliable — extremely old browser targets, embedded webviews with outdated engines, or email-client-rendered HTML.
/* custom data-table row: focus state is informational, not just a keyboard cue */
.table-row:focus {
background: var(--row-focus-bg);
}
Common Mistakes
Forgetting the fallback for outline: none
/* wrong: mouse-focus users lose ALL indication */
:focus {
outline: none;
}
/* — no :focus-visible rule follows, so keyboard users lose it too */
If you write outline: none on :focus, you must pair it with a :focus-visible rule. Removing focus indication with nothing replacing it fails WCAG 2.4.7 (Focus Visible) and is one of the most common accessibility audit findings on production sites.
Styling :focus-visible with outline: 0 and nothing else
Some teams try to "clean up" focus rings by zeroing the outline and relying on a background-color change alone. That can work, but only if the alternative indicator has sufficient contrast and is distinguishable from hover state — a focus state that looks identical to :hover gives keyboard users no way to confirm focus actually moved.
Using :focus-within where you meant :focus-visible
These solve different problems. :focus-within matches a container when any descendant has focus — useful for highlighting a form field's wrapper when the input inside it is focused. It has nothing to do with input-method detection:
.form-group:focus-within {
border-color: var(--focus-ring);
}
A Practical Default
For a new component library, start here and adjust per-component only where the decision points above apply:
*:focus {
outline: none;
}
*:focus-visible {
outline: 2px solid var(--focus-ring, #3b82f6);
outline-offset: 2px;
}
/* text inputs keep a visible focus state regardless of trigger */
input:focus,
textarea:focus {
outline: 2px solid var(--focus-ring, #3b82f6);
outline-offset: 1px;
}
Test the result with an actual keyboard — tab through the page with the mouse untouched — before shipping. Browser heuristics are good but not infallible, and the only reliable check is watching whether the indicator you expect actually shows up at the moment a keyboard user needs it.