Frontend Accessibility : Focus, Keyboard Navigation, and Inert
Authoritative reference for keyboard-first interaction primitives. Modern foundation : inert (Baseline Widely Available since April 2023) and <dialog>.showModal() replace the brittle hand-rolled focus-trap + aria-hidden + tabindex="-1"-on-all-things patterns of the 2015 to 2022 era.
Quick Reference
Baseline status
| Feature | Baseline | Source |
|---|---|---|
inert attribute |
Widely Available since April 2023 | MDN : inert (verified 2026-05-19) |
:focus-visible |
Widely Available since March 2022 | MDN : :focus-visible (verified 2026-05-19) |
:focus-within |
Widely Available since January 2020 | MDN : :focus-within (verified 2026-05-19) |
<dialog> + showModal() |
Widely Available | MDN : dialog (verified 2026-05-19) |
closedby attribute on <dialog> |
Modern (gate where legacy support matters) | MDN : dialog (verified 2026-05-19) |
The four primitives
/* 1. Focus indicator : :focus-visible, NOT :focus, NOT outline:none */
:focus-visible {
outline: 2px solid var(--focus); /* MUST meet WCAG 1.4.11 : 3:1 vs adjacent */
outline-offset: 2px;
}
/* 2. Ancestor matcher : :focus-within for parent state */
.field:focus-within { border-color: var(--accent); }
<!-- 3. Inert : declarative subtree disablement (six effects) -->
<main inert>...background while dialog is open...</main>
<!-- 4. tabindex : 0 or -1 only -->
<div role="tab" tabindex="0">Active tab</div>
<div role="tab" tabindex="-1">Other tab</div>
inert : six effects (all simultaneous)
Per MDN : inert (verified 2026-05-19), descendants of an inert element :
- Do not fire click events.
- Cannot receive focus; focus events do not fire.
- Are excluded from browser find-in-page.
- Cannot be text-selected (
user-select: nonesemantics). - Cannot be edited (input fields,
contenteditable). - Are hidden from assistive technologies (excluded from accessibility tree).
inert replaces every combination of aria-hidden + tabindex="-1" + pointer-events: none + user-select: none previously used to "disable" a subtree.
Decision Trees
Tree 1 : inert vs aria-hidden vs pointer-events: none vs disabled
Are you disabling an INDIVIDUAL form control?
YES -> use the `disabled` attribute (works on <button>, <input>, <select>,
<textarea>, <fieldset>). Provides :disabled CSS, removes from tab
order, AT announces "disabled".
NO -> next question
Are you disabling a CONTAINER (modal background, off-screen carousel slide,
hidden tab panel, side drawer's `<main>`)?
YES -> use the `inert` attribute. Removes focus + clicks + find-in-page
+ selection + editing + AT exposure simultaneously.
NO -> next question
Do you ONLY want to hide from assistive technologies (decorative element
that is still focusable / clickable for keyboard / mouse)?
YES -> use `aria-hidden="true"`. NEVER on a focusable element.
NO -> next question
Do you ONLY want to block pointer events (CSS-level paint filter, keyboard
still works)?
YES -> use `pointer-events: none`. RARE. Confirm keyboard path is OK.
NEVER combine aria-hidden="true" with a focusable element. NEVER use pointer-events: none as a substitute for inert.
Tree 2 : focus indicator selector
Is the element a non-form-control (button, link, custom widget) AND focus
indication is only needed for keyboard users?
YES -> :focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
DO NOT also set :focus { outline: none; }.
Is the element a text input where the cursor position must always be
visible (`<input>`, `<textarea>`)?
YES -> :focus + :focus-visible. The UA heuristic already shows :focus-visible
for text inputs on mouse-click; default styles are fine. Customize
via :focus-visible only.
Are you reading `:focus { outline: none }` somewhere in the codebase?
YES -> WCAG 2.4.7 violation unless `:focus-visible` provides a replacement
meeting WCAG 1.4.11 (3:1 contrast vs adjacent background). FIX or
REVERT.
Tree 3 : roving tabindex vs aria-activedescendant
Does DOM focus need to MOVE between the items as the user navigates?
YES -> roving tabindex. Active item has `tabindex="0"`, others have
`tabindex="-1"`. On arrow-key, update both attributes AND call
`element.focus()` on the new active item.
NO -> next question
Is the widget an editable combobox / searchbox where DOM focus MUST stay on
the `<input>` so typing continues?
YES -> aria-activedescendant on the container. Update the attribute to
point at the highlighted descendant's id; do NOT call focus() on
the descendant.
| Widget | Strategy |
|---|---|
Tabs (role="tablist") |
Roving tabindex |
| Listbox standalone | Roving tabindex |
| Listbox inside combobox | aria-activedescendant |
| Menubar, toolbar | Roving tabindex |
| Tree, treegrid | Roving tabindex |
Grid (role="grid") |
Roving tabindex |
| Combobox input | aria-activedescendant |
Patterns
Pattern A : focus-visible only, never bare :focus
/* CORRECT : keyboard focus visible, mouse-click on buttons not noisy */
:where(button, a, [tabindex]):focus-visible {
outline: 2px solid var(--focus, #2563eb);
outline-offset: 2px;
border-radius: 4px;
}
/* Text inputs : :focus-visible is auto-applied by UA even on mouse-click */
:where(input, textarea):focus-visible {
outline: 2px solid var(--focus, #2563eb);
outline-offset: 0;
}
The replacement outline MUST meet WCAG 1.4.11 Non-Text Contrast at 3:1 versus the background it sits on. Use oklch() and --focus token to enforce contrast at theme level.
Pattern B : tabindex - the only two legal values
<!-- 0 : focusable AND in tab order at DOM position -->
<div role="button" tabindex="0">Custom button</div>
<!-- -1 : focusable programmatically, NOT in tab order -->
<div role="dialog" tabindex="-1">...</div>
<!-- DON'T : positive values (anti-pattern) -->
<button tabindex="2">never do this</button>
Per MDN : tabindex (verified 2026-05-19) : "You are recommended to only use 0 and -1 as tabindex values."
Pattern C : modal dialog with implicit inert + focus restoration
<button id="open-dlg">Open</button>
<dialog id="my-dialog" closedby="closerequest">
<form method="dialog">
<h2>Confirm action</h2>
<p>Are you sure you want to continue?</p>
<button value="cancel">Cancel</button>
<button value="ok" autofocus>OK</button>
</form>
</dialog>
<script>
const dialog = document.getElementById('my-dialog');
const opener = document.getElementById('open-dlg');
let trigger = null;
opener.addEventListener('click', () => {
trigger = document.activeElement;
dialog.showModal(); // top layer, rest of page is implicitly inert
});
dialog.addEventListener('close', () => {
trigger?.focus(); // manual restore (NOT automatic for <dialog>)
trigger = null;
});
</script>
Per MDN : inert (verified 2026-05-19) : <dialog>.showModal() makes "the rest of the page inert" automatically. The dialog itself "escapes inertness" by living in the top layer. Tab and Shift+Tab cycle WITHIN the dialog only.
Pattern D : roving tabindex for tabs
<div role="tablist" aria-label="Sections" id="tabs">
<button role="tab" tabindex="0" aria-selected="true" aria-controls="p1" id="t1">One</button>
<button role="tab" tabindex="-1" aria-selected="false" aria-controls="p2" id="t2">Two</button>
<button role="tab" tabindex="-1" aria-selected="false" aria-controls="p3" id="t3">Three</button>
</div>
<script>
const tablist = document.getElementById('tabs');
tablist.addEventListener('keydown', (e) => {
const tabs = Array.from(tablist.querySelectorAll('[role="tab"]'));
const current = tabs.indexOf(document.activeElement);
if (current < 0) return;
let next = current;
if (e.key === 'ArrowRight') next = (current + 1) % tabs.length;
else if (e.key === 'ArrowLeft') next = (current - 1 + tabs.length) % tabs.length;
else if (e.key === 'Home') next = 0;
else if (e.key === 'End') next = tabs.length - 1;
else return;
e.preventDefault();
tabs[current].setAttribute('tabindex', '-1');
tabs[next].setAttribute('tabindex', '0');
tabs[next].focus(); // CRITICAL : moves DOM focus, not just attribute
});
</script>
Per W3C WAI APG : Tabs (verified 2026-05-19) : Left/Right arrow keys (or Up/Down when aria-orientation="vertical") cycle; Home / End jump to first / last; Tab exits the tablist.
Pattern E : background isolation for non-modal overlays
<header>...</header>
<main inert>...</main> <!-- toggled while drawer open -->
<aside class="drawer">
<button autofocus>Close</button>
<!-- drawer content -->
</aside>
For non-modal overlays (drawers, side panels, off-canvas navs that use dialog.show() or no <dialog> at all), the author MUST set inert on the rest of the page. showModal() does this automatically; show() does NOT.
Pattern F : :focus-within for parent-state styling
.field {
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.5rem 0.75rem;
}
.field:focus-within {
border-color: var(--accent);
box-shadow: 0 0 0 3px color-mix(in oklch, var(--accent) 30%, transparent);
}
Combine with :has() for cross-tree lift : .row:has(:focus-within) { background: var(--hover); }.
Out of Scope
- ARIA roles, states, and properties beyond the focus-touching subset (covered in
[[frontend-a11y-aria-patterns]]). - WCAG 2.2 success criteria narrative (covered in
[[frontend-a11y-motion-contrast-wcag22]]). - Popover API specifics,
closedby="any"light-dismiss, anchor positioning, top-layer mechanics for popovers (covered in[[frontend-impl-popover-dialog-anchor]]). - Touch-target sizing (
pointer-events, hit-targets) outside the keyboard model.
Hard Rules (Binding)
- NEVER write
:focus { outline: none }without a:focus-visiblereplacement that meets WCAG 1.4.11 (3:1 contrast). - NEVER use positive
tabindexvalues. Only0and-1are legal. - NEVER use
aria-hidden="true"on a subtree that contains focusable elements. Useinertinstead. - NEVER use
pointer-events: noneas a substitute forinert. It is a paint filter, not an interaction model. - NEVER intercept Escape inside a focus-trap implementation. Escape MUST close the dialog (per APG).
- ALWAYS capture
document.activeElementBEFORE opening a<dialog>and restore it on thecloseevent.<dialog>does NOT auto-restore (Popover API does). - ALWAYS call
element.focus()after updating roving-tabindex attributes. Attribute updates alone do not move DOM focus. - ALWAYS use the native interactive element (
<button>,<a>,<input>) when one exists.<div role="button" tabindex="0">is a six-piece reimplementation of<button>that always lacks something.
Reference Links
references/methods.md: full keyboard binding tables (1D, 2D, grid),focus()options,closedbyvalues, focus / tab / inert semantics matrixreferences/examples.md: modal dialog with focus restoration, roving-tabindex tabs, drawer withinertbackground, focus-visible CSS recipereferences/anti-patterns.md: 8 anti-patterns with symptom, root cause, fix- MDN : inert (verified 2026-05-19)
- MDN : :focus-visible (verified 2026-05-19)
- MDN : :focus-within (verified 2026-05-19)
- MDN : tabindex (verified 2026-05-19)
- MDN :
<dialog>element (verified 2026-05-19) - W3C WAI APG : Keyboard Interface (verified 2026-05-19)
- W3C WAI APG : Dialog (Modal) (verified 2026-05-19)
Cross-References
[[frontend-a11y-aria-patterns]]: roles, states, and properties for combobox, tabs, dialog, menu, listbox, treegrid[[frontend-a11y-motion-contrast-wcag22]]: WCAG 2.4.7 Focus Visible, 1.4.11 Non-Text Contrast, 2.4.11 Focus Not Obscured success criteria[[frontend-impl-popover-dialog-anchor]]: Popover API,<dialog>top-layer, anchor positioning,closedby="any"light dismiss[[frontend-syntax-html5-semantic]]: when to use<button>vs<a>vs<input type="button">[[frontend-syntax-css-has-selector]]::has(:focus-within)cross-tree highlighting