Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save arenagroove/1c3a543ad33ef4120ef99396694a83b3 to your computer and use it in GitHub Desktop.

Select an option

Save arenagroove/1c3a543ad33ef4120ef99396694a83b3 to your computer and use it in GitHub Desktop.

:focus-visible β€” Native vs. JavaScript Polyfill Comparison

Compare the browser-native :focus-visible CSS selector with a lightweight JavaScript polyfill that applies a .focus-visible class when focus is triggered via keyboard.

Why This Matters

Native :focus-visible behavior is inconsistent across browsers β€” especially for <input> and <textarea> elements. Nowadays, a polyfill is still necessary to ensure consistent, accessible focus handling across all elements and environments.

Keyboard Interaction

  • Shows the focus ring only when using Tab, Enter, or arrow keys.
  • Prevents the ring from appearing on mouse clicks.

Known Inconsistencies in Native Behavior

  • <input> and <textarea> often show a focus ring even when clicked with the mouse.
  • Programmatic focus behavior varies significantly between browsers.

Live Demo

πŸ‘‰ CodePen Demo

/*!
* A minimal polyfill for :focus-visible using a .focus-visible class
* Automatically applies to document and Shadow DOM
*/
(function focusVisiblePolyfill() {
const KEY_SET = new Set([
"Tab",
"ArrowUp",
"ArrowDown",
"ArrowLeft",
"ArrowRight",
"Enter",
" "
]);
const trackedRoots = new WeakSet();
let usingKeyboard = false;
const handleKey = (e) => (usingKeyboard = KEY_SET.has(e.key));
const handlePointer = () => (usingKeyboard = false);
const handleFocus = (e) =>
e.target.classList.toggle("focus-visible", usingKeyboard);
const handleBlur = (e) => e.target.classList.remove("focus-visible");
const attachFocusTracking = (root) => {
if (trackedRoots.has(root)) return;
trackedRoots.add(root);
root.addEventListener("focusin", handleFocus);
root.addEventListener("focusout", handleBlur);
};
const processAddedNodes = (nodes) => {
const shadowRootsToAttach = new Set();
nodes.forEach((node) => {
if (node.nodeType !== Node.ELEMENT_NODE) return;
if (node.shadowRoot) shadowRootsToAttach.add(node.shadowRoot);
node.querySelectorAll?.("[data-has-shadow]").forEach((el) => {
if (el.shadowRoot) shadowRootsToAttach.add(el.shadowRoot);
});
});
shadowRootsToAttach.forEach((root) => {
attachFocusTracking(root);
setupShadowObserver(root);
});
};
const setupShadowObserver = (root) => {
const observer = new MutationObserver((mutations) => {
const allAddedNodes = mutations.flatMap((m) => [...m.addedNodes]);
if (window.requestIdleCallback) {
requestIdleCallback(() => processAddedNodes(allAddedNodes));
} else {
processAddedNodes(allAddedNodes);
}
});
observer.observe(root, { childList: true, subtree: true });
};
document.addEventListener("keydown", handleKey);
document.addEventListener("pointerdown", handlePointer, { passive: true });
attachFocusTracking(document);
document.querySelectorAll("[data-has-shadow]").forEach((el) => {
if (el.shadowRoot) attachFocusTracking(el.shadowRoot);
});
setupShadowObserver(document);
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment