focus-visible review
A mouse click on a writable text field can still get a focus ring. focus-visible 5.2.1 is a browser polyfill for the :focus-visible CSS pseudo-class. The polyfill adds a focus-visible class to focused elements when its heuristics call for a visible indicator. Writable text inputs and textareas can receive the class even after a pointer click. That exception matters when you want to quiet a button's mouse-focus outline without hiding the indicator on a field someone is about to type into. The maintainers recommend native :focus-visible CSS where possible, so check your browser requirements before adding this dependency.
Keep focus-visible for a browser requirement or an existing class-based integration that still needs it. If native :focus-visible meets your requirements, start with the defensive CSS recipe below and check the resulting rings before removing the dependency.
Answers from our run
focus-visible or what-input: which should you use?
what-input: Choose what-input when you need to track the current mouse, keyboard or touch input method across the page and write your own styling rules. That is a broader input-tracking task than applying this polyfill's per-element class. Keep focus-visible for a browser requirement or an existing class-based integration that still needs it.
When should you not use focus-visible?
Your supported browsers already implement :focus-visible. The project recommends using native CSS where you can, which avoids loading this script.
Use it if
- Your browser requirements include an environment without native :focus-visible, and you need focus-ring styling that distinguishes pointer use from keyboard use.
- An existing site already uses .js-focus-visible and .focus-visible selectors, and you need to understand those rules before removing the script.
- Your framework replaces element classes, so you want the documented data-attribute selectors instead of depending on the polyfill's class names.
- A web component needs the same polyfill behavior inside a shadow root, and you can own that root's initialization and CSS.
- Your supported browsers already implement :focus-visible. The project recommends using native CSS where you can, which avoids loading this script.
- You expect routine feature releases. The maintainers say they have no planned updates or new releases, while leaving room for emergency changes.
- You want to remove every mouse-focus ring, including writable text fields. The implementation deliberately treats text-entry controls differently.
- You need a modal focus trap or focus restoration. Those are separate jobs from choosing when a focused element gets a CSS class.
- You cannot initialize shadow roots yourself. Loading the document script does not automatically apply the polyfill inside each component.
Setup reality
Install the npm package as focus-visible@5.2.1. The published manifest points main and module at dist/focus-visible.js. For a site that serves public/ as its static root, the first two recipes copy the minified browser script into public/vendor/ and load that local URL near the end of body. Adapt the destination to your build system. Installing an npm dependency does not make a node_modules URL publicly reachable. The remaining recipes are independent illustrations, not steps to paste together. We inspected the npm package archive, version-matched README and source. We did not run these examples in a browser or measure installation speed, bundle output or accessibility outcomes.
Keep an ordinary :focus style before adding any suppression rule. The class recipe removes an outline only beneath .js-focus-visible, which the script adds to the document element. If JavaScript never runs, that guarded rule does not match and the basic indicator remains. For a framework that overwrites classes, use the documented data-js-focus-visible and data-focus-visible-added selectors instead. Choose one convention consistently. A manually supplied focus-visible class is a separate way to keep an indicator on a particular control.
The details are in the 5.2.1 implementation. Its keydown handler enters keyboard mode unless Alt, Ctrl or Meta is pressed, rather than checking only for Tab. Writable allowlisted input types, writable textareas and contenteditable elements also request the class when focused. A read-only field skips the text-entry shortcut, but it can still get a ring through keyboard mode. Do not mistake this heuristic for a complete account of someone's accessibility needs.
Shadow roots need both an explicit applyFocusVisiblePolyfill(root) call and styles inside the root. The helper marks the host with data-js-focus-visible, so a document-level .js-focus-visible selector alone is not the component's styling solution. If the script loads late, check whether the helper already exists before waiting for focus-visible-polyfill-ready. The event is dispatched once when the script loads, and waiting only for a future event can miss it. Assign one initialization path per root.
The version-matched README recommends native CSS and says routine polyfill releases are not planned. The 5.2.1 release note describes removing a malicious polyfill-service reference from the demo and docs. That release note does not announce a new focus algorithm. If your task is keeping focus inside a React dialog, the react-focus-lock guide covers that separate decision.
Before shipping either the polyfill or the native-CSS recipe, check Tab traversal, pointer clicks on buttons and writable fields, JavaScript disabled, and the controls inside each shadow root. Inspect the actual indicator against your page backgrounds. The scores below are editorial judgments from documentation and source, using the rubric in each rationale. They are not browser test results or adoption measurements.
Patterns
Install and copy the browser file install-and-copy-browser-script
npm install --save-exact focus-visible@5.2.1
mkdir -p public/vendor
cp node_modules/focus-visible/dist/focus-visible.min.js public/vendor/focus-visible.min.jsAll recipes on this page are unexecuted documentation-based illustrations for 5.2.1. These shell commands assume public/ is your static document root. They copy a browser asset, not a stylesheet. Match the served URL to your deployment and include the file-copy step in your build.
Load the copied script near the end of body load-local-browser-script
<button type="button">Save settings</button>
<script src="/vendor/focus-visible.min.js"></script>Place this HTML inside body after the page controls. The URL assumes the copy destination in the install recipe. The script initializes the document automatically, but shadow roots need their own helper calls. A script tag alone does not add your chosen focus-ring CSS.
Keep a fallback ring when JavaScript is absent guard-class-focus-rules
button:focus,
a[href]:focus {
outline: 2px solid #0757b8;
outline-offset: 3px;
}
.js-focus-visible button:focus:not(.focus-visible),
.js-focus-visible a[href]:focus:not(.focus-visible) {
outline: none;
}The script adds .js-focus-visible to the document element. Keeping suppression beneath that marker preserves the basic :focus rule if the script never starts. This example targets buttons and links only. Check the chosen outline color against your actual backgrounds.
Use attributes when a framework replaces classes use-attribute-focus-rules
.action:focus {
outline: 2px solid #0757b8;
outline-offset: 3px;
}
[data-js-focus-visible] .action:focus:not([data-focus-visible-added]) {
outline: none;
}Use this instead of the class-based suppression rule for controls with class="action". The README documents these attributes for frameworks that overwrite classes. The per-element attribute identifies a class added by the polyfill. A manually supplied .focus-visible class does not receive that ownership attribute, so do not combine this rule with the manual-class recipe.
Keep an indicator on a chosen button keep-a-control-ring
<style>
button:focus {
outline: 2px solid #0757b8;
outline-offset: 3px;
}
.js-focus-visible button:focus:not(.focus-visible) {
outline: none;
}
</style>
<button type="button" class="focus-visible">Review payment</button>In the class-based approach, supply .focus-visible yourself before the script handles focus. The implementation leaves an existing class alone and removes classes on blur only when its data-focus-visible-added ownership marker is present. The persistent class does not move focus or display this outline on an unfocused button.
Keep writable text fields visible after a click style-text-entry-focus
<style>
input:focus {
outline: 2px solid #0757b8;
outline-offset: 3px;
}
.js-focus-visible input:focus:not(.focus-visible) {
outline: none;
}
</style>
<label>
Email
<input type="email" name="email" autocomplete="email">
</label>The 5.2.1 allowlist includes email inputs, so a writable focused field requests the class even after pointer use. A readOnly input skips that shortcut, though keyboard mode can still give it the class. Do not use the presence of a ring as proof that the last interaction was a key press.
Apply the polyfill and CSS inside a shadow root initialize-shadow-root
var host = document.createElement('div');
var root = host.attachShadow({ mode: 'open' });
root.innerHTML =
'<style>' +
'button:focus { outline: 2px solid #0757b8; outline-offset: 3px; }' +
':host([data-js-focus-visible]) button:focus:not(.focus-visible) { outline: none; }' +
'</style><button type="button">Component action</button>';
document.body.appendChild(host);
if (typeof window.applyFocusVisiblePolyfill === 'function') {
window.applyFocusVisiblePolyfill(root);
}Run after body exists and, for polyfill behavior, after the script loads. This recipe requires a browser with attachShadow. The helper marks the host with data-js-focus-visible, and the component's own :host selector guards suppression. Without the helper, its basic focus style remains. Use the late-loading recipe if script order is uncertain.
Handle a polyfill that may already have loaded wait-for-late-polyfill
var host = document.querySelector('focus-card');
var root = host && host.shadowRoot;
if (root) {
if (typeof window.applyFocusVisiblePolyfill === 'function') {
window.applyFocusVisiblePolyfill(root);
} else {
window.addEventListener('focus-visible-polyfill-ready', function () {
window.applyFocusVisiblePolyfill(root);
}, { once: true });
}
}This assumes an existing focus-card element with an open shadow root and its own focus styles. Run this initialization path once for that root instead of also calling the helper in another recipe. The global event fires at script load, so first checking the helper avoids waiting for an event that already happened. This code does not create a later-mounted component.
Use native CSS while preserving an older-browser fallback replace-with-defensive-native-css
button:focus {
outline: 2px solid #0757b8;
outline-offset: 3px;
}
button:focus:not(:focus-visible) {
outline: none;
}
button:focus-visible {
outline: 3px solid #0757b8;
outline-offset: 3px;
}This is the README's defensive native-CSS structure, without the polyfill. Keep the rules separate: a browser that rejects the :focus-visible selectors can retain the first :focus rule. Adapt it to the other focusable controls you own. Check the browser requirements and the resulting indicators before removing the script and its old CSS.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| what-input | npm | Choose what-input when you need to track the current mouse, keyboard or touch input method across the page and write your own styling rules. That is a broader input-tracking task than applying this polyfill's per-element class. |
| @react-aria/focus | npm | Choose @react-aria/focus when a React component needs useFocusRing() state and props to decide its own ring styling. It is a component-level approach rather than a document-wide class polyfill. |
More web frontend guides
postcss · react · react-dom · tailwindcss · htmlparser2 · tailwind-merge · the whole shelf →
How this guide is made: grounded in the library's documentation, release notes, changelog, and issue history, on a fixed rubric — not a hands-on install of every release. The 50 most-downloaded entries are additionally install-verified in clean containers. Corrections: contact the desk.