The instrument, not the folklore
The difference is a mechanism you can see, not a rule you must trust.
One button. Two modalities. The naive reset strips the ring for everyone. The heuristic draws it only for the reader who needs it. This post lets you watch that decision fire.
focus vs focus-visible, made observable: watch the heuristic fire
The modality heuristic behind :focus-visible is not folklore. It is a decision you can watch the browser make, then style with confidence.
Why outline: none on :focus became an epidemic#
Almost every codebase carries the same line somewhere. Someone disliked the ring a mouse click leaves behind. So they wrote outline:none and moved on. However, that reset does not target mouse users. It targets the :focus state, and :focus matches keyboard focus too. Consequently the keyboard user loses the one cue that tells them where they are.
This is the heart of the focus vs focus-visible confusion. The two selectors look like siblings, yet they answer different questions. :focus asks whether an element has focus at all. :focus-visible asks whether the browser thinks a ring belongs there. Because the naive reset conflates them, it fixes a cosmetic complaint and creates an accessibility defect in one stroke.
What :focus actually matches#
Start with the plain selector. :focus matches whenever an element holds focus, full stop. It does not care how focus arrived. Click a button, and :focus matches. Tab to it, and :focus matches. The MDN reference for :focus states this plainly. So any style you hang on :focus applies to the mouse case you wanted gone and the keyboard case you needed to keep.
The harm, concretely#
Watch the heuristic fire: the modality inspector#
Folklore is hard to trust, so stop trusting it and watch it instead. Below sits the same button and the same text input, each focusable three ways. First press Tab to reach them. Next click them with a pointer. Then use the script triggers to move focus with el.focus(). As focus lands, the panel reads the browser's own matches() result and lights which of :focus, :focus-visible, and :focus-within are true right now.
Focused: nothing yet. Focus the button or the input below by any of the three paths, then read the live verdict here.
| Situation | :focus | :focus-visible | Ring drawn? |
|---|---|---|---|
| Tab to the button | matches | matches | Yes |
| Click the button | matches | no match | No |
| Focus the input (any path) | matches | matches | Yes |
| Script focus after a key press | matches | matches | Yes |
| Script focus after a click | matches | no match | No |
Tab to it: the ring appears#
Reach the button with the keyboard first. The last interaction the browser saw was a key press. Therefore the heuristic marks the focus keyboard-driven and :focus-visible matches. Watch both the :focus and :focus-visible indicators light together, and watch the real ring appear on the control. This is the case everyone agrees on, and the demo confirms it fires exactly as the folklore promised.
Click it: no ugly ring#
Now click the same button with a pointer. The :focus indicator lights, because the button truly holds focus. However, :focus-visible stays dark, and no ring is drawn. Nothing in your stylesheet made that choice. Instead the browser applied its rule that a pointer focus on a non-text control does not warrant a ring. That is the mouse-click ring you disliked, gone without a single outline:none.
Script moves focus: inheritance kicks in#
The third path is the one rivals skip. Move focus with a script and the outcome depends on what came before. Press Enter on a script trigger, and the prior interaction was keyboard, so the button inherits that context and shows the ring. Click the trigger instead, and the prior interaction was a pointer, so a plain button stays ringless. The inheritance rule is subtle, and the inspector makes it visible.
// Move focus with a script AFTER a key press, and the target inherits the
// keyboard context: :focus-visible matches, so the ring is drawn.
document.querySelector('#next-step').focus();
// Move focus AFTER a mouse click, and a plain button inherits the pointer
// context instead: :focus matches, :focus-visible does not, so no ring shows. The actual rules: what the browser decides, and when#
Observation is the hook, but the rules are the payoff. The modality heuristic is not magic. It is a small set of decisions the user agent makes from the last interaction type and the element's kind. Because those decisions are specified, you can reason about every case instead of guessing. Here is the whole rule set, laid out.
The decision table#
Read this grid as the heuristic in full. Each row is a situation, and the last two columns are what the browser decides. Notice that :focus matches in every row, while :focus-visible is selective. That selectivity is the entire feature.
| Situation | Last interaction | :focus | :focus-visible | Ring shown |
|---|---|---|---|---|
| Tab navigation to a button | Last interactionkeyboard | :focusmatches | :focus-visiblematches | Ring shownYes |
| Mouse click on a button | Last interactionpointer | :focusmatches | :focus-visibleno match | Ring shownNo |
| Click on a text input | Last interactionpointer | :focusmatches | :focus-visiblematches | Ring shownYes (text override) |
| Programmatic focus after a click | Last interactionpointer | :focusmatches | :focus-visibleno match | Ring shownNo |
| Programmatic focus after a key press | Last interactionkeyboard | :focusmatches | :focus-visiblematches | Ring shownYes (inheritance) |
| OS "always show focus" is on | Last interactionany | :focusmatches | :focus-visiblematches | Ring shownYes (preference) |
The modality state machine#
The same rules read cleanly as a flow. Focus arrives by keyboard, pointer, or script. From there the browser branches on the element type and the prior state. The diagram traces every branch, including the text-input override and the programmatic-focus inheritance path.
The text-input override#
One branch surprises people. A text field shows its ring even on a mouse click. The reason is usability, not inconsistency. You must see where the caret sits before you type, so the browser always indicates focus on controls that accept keyboard text. The Selectors Level 4 draft defines this in its :focus-visible specification. Consequently the same selector behaves differently across element types.
/* You do not author this behaviour. The browser's heuristic applies it for you.
A control that accepts keyboard text shows its ring under EVERY modality,
because you must see where you are about to type. */
button:focus-visible { outline: 3px solid #0b6bcb; } /* keyboard only */
input:focus-visible { outline: 3px solid #0b6bcb; } /* keyboard AND mouse */
/* Same selector, two outcomes. Click the button and it does not match, so no
ring. Click the input and it still matches, so the ring stays on. The element
type decides, not your CSS. */ Programmatic-focus inheritance#
Here is the edge case rivals never state. When a script moves focus away from an element that matched :focus-visible, the newly focused element matches too. In other words, the keyboard context is inherited across a programmatic move. This matters for menus, dialogs, and single-page routing, where you call el.focus() after a keyboard action. Because the ring follows the user, a keyboard-driven flow stays legible even when your code drives the focus.
The OS "always show focus" preference#
The accessibility stakes: WCAG 2.2, not just 2.4.7#
Most articles stop at one success criterion and call it accessibility. WCAG 2.2 added two more that bear directly on focus. So a complete answer covers three criteria, not one. Moreover, the newest one carries concrete geometry you can compute. Let us take them in order.
2.4.7 Focus Visible (AA)#
This is the baseline every rival covers. SC 2.4.7 requires that a keyboard focus indicator is visible whenever a component receives focus. It is a Level AA criterion, and it has been in the guidelines for years. The Understanding 2.4.7 document spells out the intent. In practice, the naive outline reset is the single most common way to fail it.
2.4.11 Focus Not Obscured (AA, new in 2.2)#
Few articles mention this one, yet it is Level AA. SC 2.4.11 requires that the focused component is not entirely hidden by author-created content. Sticky headers, cookie banners, and toolbars are the usual culprits. Because a focused control scrolled under a sticky bar is invisible, a ring alone does not save you. The Understanding 2.4.11 document covers the layout traps. In short, drawing the ring is necessary but not sufficient.
2.4.13 Focus Appearance (AAA, new in 2.2): the geometry#
This criterion turns focus into numbers. SC 2.4.13 asks that the indicator be large enough and contrast enough to be unmissable. The diagram below annotates a 120 by 40 CSS-pixel button with the exact figures the criterion demands. Read the callouts as the arithmetic behind a single outline rule.
- 2px perimeter bandSC 2.4.13 requires a focus indicator at least as large as a 2 CSS-pixel-thick perimeter of the component.
- 624 px^2 floorThat band is (120 x 40) minus (116 x 36), which is 4800 minus 4176, which is 624 CSS px^2. That is the minimum indicator area.
- ~640 px^2 ringA 2px outline around the full perimeter covers roughly 2 x (120 + 40) x 2, about 640 px^2, so it clears the 624 floor.
- 3:1 contrastThe indicator must also reach a 3:1 contrast ratio between the focused and unfocused states, or it does not count.
One selector, three outcomes#
Now feel the numbers. A single :focus-visible rule produces three different amounts of ring, decided entirely by modality and element type. The button drops to zero ring on a click and jumps to a full ring on a Tab. The input keeps its full ring on every path. One rule, three outcomes, no author branching.
0 px^2
Button, clicked with a pointer
~640 px^2
Button, reached by Tab
~640 px^2
Text input, any modality
The button click draws no ring, while a Tab and every text-input focus draw the full ~640 px^2. You wrote one rule; the modality heuristic produced all three outcomes. These figures follow the cited WCAG 2.2 geometry, not an Atyantik benchmark.
| Option | ring area in CSS px^2 for a 120x40 control, per SC 2.4.13 geometry |
|---|---|
| Button, clicked with a pointer | 0 px^2 |
| Button, reached by Tab | ~640 px^2 |
| Text input, any modality | ~640 px^2 |
The WCAG 2.2 mapping table#
Here are the three criteria as a lookup. Keep it beside your focus styles as a checklist. Two of the three are new in WCAG 2.2, which is exactly why older tutorials never mention them.
| Success criterion | Level | New in 2.2? | What it requires |
|---|---|---|---|
| 2.4.7 Focus Visible | LevelAA | New in 2.2?No | What it requiresA keyboard focus indicator is visible whenever a component receives focus. |
| 2.4.11 Focus Not Obscured (Minimum) | LevelAA | New in 2.2?Yes | What it requiresThe focused component is not entirely hidden by author-created content such as sticky bars. |
| 2.4.13 Focus Appearance | LevelAAA | New in 2.2?Yes | What it requiresThe indicator covers at least a 2px perimeter (about 624 px^2 here) at a 3:1 contrast against adjacent colours. |
For the broader build-quality picture around this, our guide to building a frontend that holds up under real users covers the same discipline applied to performance and crawlability. Accessibility and performance are siblings, not competitors.
The correct 2026 focus vs focus-visible pattern#
Pre-2022 advice assumed you had to fight the default outline. That assumption is now stale. Browsers switched their own user-agent stylesheets to :focus-visible, so the ring you disliked already only shows for keyboard users on modern engines. Therefore the naive outline:none reset is often redundant and always risky. Here is the pattern that replaces it.
Reset only where you replace#
Compare the two approaches directly. The first tab is the reset every dated tutorial still ships. The second tab is the honest version. Read the difference and never write the first one again.
/* The pattern every dated tutorial still ships. It strips the ring for EVERYONE,
keyboard users included, because :focus matches a mouse click too. This is the
WCAG 2.4.7 violation the whole article is about. */
button:focus {
outline: none;
} /* The honest 2026 pattern. Remove the ring ONLY where the browser would not have
drawn one anyway (a pointer focus), then draw your own ring for the keyboard
modality with :focus-visible. Keyboard users keep their wayfinding. */
button:focus:not(:focus-visible) {
outline: none;
}
button:focus-visible {
outline: 3px solid #0b6bcb;
outline-offset: 2px;
} Keep an always-visible indicator#
:focus-within for containers#
The container selector rounds out the toolkit. :focus-within matches an ancestor while any descendant holds focus. Use it to lift a form group, a card, or a menu the moment a control inside it gains focus. Note that it is not a modality signal. It fires on both mouse and keyboard focus, so pair it with :focus-visible on the inner control when the ring should stay keyboard-only. The MDN reference for :focus-within lists the details.
/* :focus-within styles a CONTAINER while any descendant holds focus. Use it to
lift a form group or a menu the moment a control inside it is focused. It is
not a modality signal; it fires on both mouse and keyboard focus. */
.field-group:focus-within {
border-color: #0b6bcb;
box-shadow: 0 0 0 3px #cfe6ff;
} The progressive-enhancement fallback#
Old engines still visit your site. So ship a fallback that degrades safely. Draw a ring on plain :focus first, then remove it wherever :focus-visible is supported and does not match. Because a legacy engine ignores the selector it does not understand, it keeps the always-on ring rather than losing it. This is the copy-paste block the WICG project popularised.
/* Progressive enhancement for engines that predate native :focus-visible.
Draw a ring for plain :focus first, then REMOVE it wherever :focus-visible is
supported and does not match. Old engines ignore the second rule and keep the
always-on ring, so nobody ever loses a focus indicator. */
:focus {
outline: 3px solid #0b6bcb;
outline-offset: 2px;
}
:focus:not(:focus-visible) {
outline: none;
} From polyfill to native: the migration#
None of this arrived overnight. The selector took years to travel from a JavaScript polyfill to a native feature in every engine. That history explains why so much stale advice still circulates. The timeline below traces the journey.
- 2018
The WICG polyfill
The focus-visible polyfill lands. Authors add a .focus-visible class through focus-visible.js and style that class as a stand-in.
- 2019
Firefox ships :-moz-focusring
Firefox exposes its own vendor pseudo-class, the first native modality signal in a shipping engine.
- 2020
Chromium ships native :focus-visible
Chrome and Edge enable the standard pseudo-class, so the polyfill class becomes optional on those engines.
- 2021
Safari ships :focus-visible
WebKit adds native support, so all three major engines finally agree on the selector.
- 2022 onward
User-agent stylesheets adopt it
Browser default stylesheets switch their own outlines to :focus-visible, which is why a naive outline:none reset is now often redundant and harmful.
The WICG focus-visible project still documents the polyfill and its class-based fallback. Read it as a historical record, not a current dependency, because native support is now broad. This kind of state-driven interface thinking shows up across modern UI work, as our walkthrough of state-driven UX patterns in modern interfaces explores.
Cross-browser reality#
Each user agent defines the heuristic, so engines can diverge on the tricky cases. Everyday focus agrees across all three. However, programmatic focus is where you should test your own targets rather than trust a table. The matrix below marks where behaviour has historically differed.
| Scenario | Chrome / Edge | Firefox | Safari |
|---|---|---|---|
| Tab to any control | Chrome / EdgeRing shown | FirefoxRing shown | SafariRing shown |
| Mouse click on a button | Chrome / EdgeNo ring | FirefoxNo ring | SafariNo ring |
| Click on a text input | Chrome / EdgeRing shown | FirefoxRing shown | SafariRing shown |
| Programmatic .focus() after Tab | Chrome / EdgeRing shown | FirefoxRing shown | SafariRing shown |
| Programmatic .focus() after a click | Chrome / EdgeUsually no ring | FirefoxUsually no ring | SafariHas historically differed |
Read the last row as a warning, not a verdict. Because the specification leaves room for judgement on programmatic focus, an engine may draw a ring where another does not. So verify the exact controls you ship, and lean on the inspector above to see what your browser does today. Component-level focus handling is a recurring theme in our guide to scalable UI component patterns.
When NOT to reach for :focus-visible#
There is one more trap to name. Do not treat :focus-visible as a way to hide focus. It exists to make focus tasteful, never invisible. If your reason for using it is that rings look ugly, revisit the goal. The right ring, drawn at the right moment, is a feature. The focus vs focus-visible split gives you that precision without sacrificing a single keyboard user.