Keyboard Navigation & Focus Management (inert)
Mastering keyboard navigation and the HTML `inert` attribute is what separates interfaces that merely look accessible from ones that actually work for keyboard and assistive-technology users.
9 min read
The full lesson
Millions of people navigate the web using only a keyboard. That includes people with motor impairments, screen reader users, power users, and anyone dealing with a temporary injury. When keyboard support is broken, these users hit dead ends, lose their place, or get trapped inside a component they can’t escape. WCAG 2.2 dedicates an entire principle — Operable — to making sure every function is reachable without a mouse. The HTML inert attribute, now available in all major browsers, gives developers a clean, semantic way to enforce focus boundaries without fragile JavaScript workarounds.
Why Keyboard Access Is Foundational
Pointer devices like mice and trackpads are actually the most fragile input method. Keyboards, switch controls, sip-and-puff devices, and screen readers all route through the same underlying focus system. Fix the keyboard experience and you largely fix the entire non-pointer stack.
The cost of getting this wrong is real. WCAG 2.2 Success Criterion 2.1.1 (Keyboard, Level AA) requires that all content and functionality be operable by keyboard without requiring specific timings. SC 2.1.2 (No Keyboard Trap, Level AA) prohibits trapping focus with no way out. A modal that can’t be closed with Escape, or a custom widget that swallows Tab presses, is a direct conformance failure — and a growing legal risk under the European Accessibility Act (enforced from June 2025), the ADA, and Section 508.
The Focus Order Contract
Every interactive element on a page participates in a focus order — the sequence a user moves through with Tab (forward) and Shift+Tab (backward). That order must be logical and predictable (WCAG 2.4.3 Focus Order, AA).
By default, browsers derive tab order from DOM order (the order elements appear in the HTML). CSS layout can make elements appear in a completely different sequence from how they sit in the DOM. This happens with CSS Grid reordering, position: absolute elements, and the flex order property. When visual order and DOM order diverge, keyboard users navigate a path that doesn’t match what they see.
The golden rule: match DOM order to visual reading order. Don’t use tabindex values greater than 0 to force a different sequence. Positive tabindex values create a separate, higher-priority tab queue that runs before the natural DOM order. It’s a brittle mechanism that breaks whenever the page structure changes.
tabindex value | Effect | When to use |
|---|---|---|
| Not set | Element joins the natural DOM tab order (if it’s natively focusable) | Default for all native interactive elements |
tabindex="0" | Adds a non-native element to the natural tab order | Custom interactive widgets (e.g., a div-based card that acts as a button) |
tabindex="-1" | Removes from tab order; still focusable via JavaScript | Programmatic focus targets: dialog containers, toast anchors, skip-link destinations |
tabindex="1" or greater | Creates a high-priority queue above the natural order | Never use. Creates unpredictable navigation and is an anti-pattern. |
Focus Indicators: Visibility Is Not Optional
One of the most widespread accessibility failures is outline: none with no replacement. Developers remove the browser’s default focus ring for aesthetic reasons and don’t provide an alternative. WCAG 2.4.7 Focus Visible (AA) has always required some visible indicator, and WCAG 2.2’s 2.4.11 adds the rule that the focused element must not be completely hidden.
The modern best practice is to design a custom focus indicator that is:
- High contrast. WCAG 2.2 AAA criterion 2.4.13 Focus Appearance specifies a 3:1 contrast ratio between the focus indicator and adjacent colors, an area of at least 2 CSS pixels around the component perimeter, and a minimum indicator size. Even if you’re targeting AA, this is a useful design target.
- Size-independent. Use
outlineandbox-shadowrather thanborder(which shifts layout), and size withemso the indicator scales with the element. - Applied selectively. Use
:focus-visibleinstead of:focusto show the ring only during keyboard or programmatic focus — not on mouse click. This keeps a clean look for pointer users without stripping the indicator for keyboard users.
/* Modern pattern: custom focus visible indicator */
:focus-visible {
outline: 3px solid oklch(65% 0.2 260);
outline-offset: 3px;
border-radius: 4px;
}
/* Suppress the outline on mouse/touch focus — non-keyboard users */
:focus:not(:focus-visible) {
outline: none;
}
Do
- Use
outline+outline-offsetfor focus rings — they don’t shift layout and can be styled independently of the element’s border. - Apply
:focus-visibleto show indicators only during keyboard navigation, keeping a clean look for pointer users without hiding the indicator from keyboard users. - Design focus indicators with at least 3:1 contrast against both the focused element and the surrounding background.
- Test your entire UI by unplugging the mouse and tabbing through every interactive element.
- Use
tabindex="-1"on dialog containers and skip-link targets so JavaScript can move focus there programmatically.
Don't
- Write
outline: noneoroutline: 0without providing an equivalent replacement — this is the single most common keyboard accessibility failure. - Use
tabindexvalues of 1 or higher to reorder focus; fix DOM order instead. - Rely on
:focuswithout:focus-visible— it shows the ring on every click, which prompts designers to suppress it for everyone, including keyboard users. - Allow sticky headers, floating toolbars, or cookie banners to fully cover the focused element — this is a WCAG 2.2 AA failure (2.4.11).
- Forget to test focus indicators against your dark mode theme; a white outline on a near-white surface is effectively invisible.
Managing Focus in Dynamic Interfaces
Static pages are relatively easy to make keyboard-accessible. The hard problems appear when the DOM changes: modals open, drawers slide in, alerts appear, content loads. JavaScript must actively manage focus in all of these cases.
Opening and Closing Dialogs
When a modal opens, focus must move into it immediately — usually to the first focusable element, or to the dialog container itself (using tabindex="-1"). When the modal closes, focus must return to the trigger element that opened it. If focus stays on its previous position behind the closed modal, keyboard users are left somewhere arbitrary on the page with no indication of what just happened.
// Simplified dialog focus pattern
function openDialog(dialog, trigger) {
dialog.removeAttribute('inert');
dialog.querySelector('[autofocus], [tabindex="-1"]').focus();
}
function closeDialog(dialog, trigger) {
dialog.setAttribute('inert', '');
trigger.focus(); // Return focus to where the user was
}
Skip Navigation Links
Every page with repeated navigation blocks must give keyboard users a way to bypass them — WCAG 2.4.1 Bypass Blocks (AA). The standard solution is a “Skip to main content” link as the first focusable element on the page. It’s usually visually hidden until focused, using a clip-path or position: absolute; left: -9999px technique that keeps it in the accessibility tree.
.skip-link {
position: absolute;
left: -9999px;
top: auto;
width: 1px;
height: 1px;
overflow: hidden;
}
.skip-link:focus {
left: 16px;
top: 16px;
width: auto;
height: auto;
overflow: visible;
z-index: 9999;
}
The destination — usually the main element — should have tabindex="-1" so JavaScript can move focus there programmatically. It won’t appear in the tab sequence during regular navigation.
The inert Attribute: The Modern Focus Trap Solution
Before inert, trapping focus inside a modal required a fragile JavaScript approach: listening for Tab and Shift+Tab key events, finding the first and last focusable descendants, and manually wrapping focus around them. You had to account for hidden elements, disabled elements, elements with display: none, and every edge case in the focusable-element specification.
The HTML inert attribute solves this at the platform level. Adding inert to an element makes that element and all its descendants:
- Non-focusable — removed from the tab order
- Non-interactive — pointer events are suppressed
- Hidden from the accessibility tree — screen readers skip it entirely
<!-- Modal is open: everything outside it is inert -->
<header inert>...</header>
<main inert>...</main>
<aside inert>...</aside>
<div role="dialog" aria-modal="true" aria-labelledby="dialog-title">
<h2 id="dialog-title">Confirm deletion</h2>
<!-- Focus is naturally contained here — no JS key-trapping needed -->
<button>Cancel</button>
<button>Delete</button>
</div>
<footer inert>...</footer>
When the dialog closes, remove inert from the background content and restore focus to the trigger. The browser handles the rest.
Browser Support and Progressive Enhancement
inert became available across Chrome, Firefox, Safari, and Edge in late 2023. As of 2026, you can use it in production without a polyfill for any current browser. If you need to support older browsers in rare cases, the WICG inert polyfill exists, but it emulates the behavior in JavaScript and carries performance caveats.
If you’re still using JavaScript key-trapping and haven’t migrated yet, that pattern still works — but it requires careful handling of Tab and Shift+Tab wrapping logic and must be re-validated whenever the number of focusable elements changes.
Composite Widgets and the ARIA Keyboard Interaction Model
Not all keyboard interaction is about Tab focus. Complex widgets — menus, tabs, listboxes, trees, grids — use a pattern called roving tabindex, where only one item in the group is in the tab sequence at a time and arrow keys navigate within the group. This is the WAI-ARIA Authoring Practices Guide (APG) model.
Here’s how roving tabindex works:
- Give the active item
tabindex="0"and all other items in the grouptabindex="-1". - When the user presses an arrow key, move focus with
.focus()and update which item holdstabindex="0". Tabexits the entire widget, moving to the next element in the page order.
This preserves the mental model users already have: Tab moves between major page regions; arrow keys navigate within a control. It’s the same model as a desktop application.
<!-- Tab panel widget using roving tabindex -->
<div role="tablist" aria-label="Settings sections">
<button role="tab" tabindex="0" aria-selected="true"
aria-controls="panel-general" id="tab-general">General</button>
<button role="tab" tabindex="-1" aria-selected="false"
aria-controls="panel-privacy" id="tab-privacy">Privacy</button>
<button role="tab" tabindex="-1" aria-selected="false"
aria-controls="panel-billing" id="tab-billing">Billing</button>
</div>
The WAI-ARIA APG documents the expected keyboard interactions for every widget type. Before building a custom interactive component, check the APG pattern library — the interaction model is already specified so you don’t have to invent it.
Testing Keyboard Navigation
No automated tool can fully verify keyboard accessibility. Focus behavior depends on runtime state. Manual testing is required.
A practical keyboard audit checklist:
- Unplug (or disable) the mouse and tab through every page.
- Verify every interactive element receives visible focus.
- Verify focus order matches the visual reading order.
- Open every modal, drawer, and popover. Confirm focus moves in,
Tabstays inside, andEscapecloses and returns focus to the trigger. - Verify skip links appear and work on the first
Tabpress. - Navigate every composite widget — tabs, menus, date pickers — with arrow keys.
- Check that dynamically injected content (search results, form errors, toasts) is announced, either through focus movement or
aria-liveregions, not just visual rendering.
Automated tools like axe, Lighthouse, and WAVE can flag missing labels, incorrect roles, and absent skip links. But they can’t detect incorrect focus order or broken focus traps. Manual testing with a real screen reader is the only way to verify the full experience. Use NVDA + Firefox on Windows, VoiceOver + Safari on macOS and iOS, and TalkBack on Android.
Focus Management in Single-Page Applications
Single-page applications (SPAs) replace HTML content without a browser navigation event. The browser’s built-in behavior — scrolling to the top and focusing the document on navigation — does not fire. Without manual intervention, a keyboard user who activates a link in an SPA’s navigation goes nowhere perceptible. The URL changes, the page content changes, but focus stays on the nav link and nothing is announced.
The standard SPA focus management pattern:
- After a route change, move focus to a “page title” heading (an
h1withtabindex="-1") or a skip-link-equivalent container. - Announce the new page title via a visually-hidden ARIA live region (
role="status"oraria-live="polite") so screen reader users hear that navigation happened. - Scroll to the top of the new view.
React Router, Next.js, SvelteKit, and Nuxt all handle this differently. As of 2026, the Next.js App Router manages focus and scroll restoration on navigation automatically. React Router v6 does not manage focus by default and requires a useEffect-based focus management hook.