Keyboard and Focus Management
Everything must work with the keyboard alone (WCAG 2.1.1) and the focused element must be visible (2.4.7). The hard parts in SPAs: trapping focus inside a modal, restoring focus when it closes, moving focus on route changes (otherwise it’s stranded after the old page unmounts), and the roving tabindex pattern for composite widgets. Never outline: none without a replacement focus style.
What’s the baseline for keyboard support?
Tab/Shift+Tab move between focusable elements in DOM order; Enter activates links/buttons; Space activates buttons and toggles checkboxes; Arrow keys move within composite widgets (menus, tabs, radio groups). If a feature only works on mouse hover or click, it fails. Use semantic elements so you get this free (Semantic HTML and Landmarks).
How does tabindex work?
tabindex="0"— adds a non-focusable element to the natural tab order (use for custom widgets).tabindex="-1"— focusable only via JS (el.focus()), not Tab — for managing focus programmatically (modal containers, route targets).tabindex="1+"— anti-pattern: jumps ahead of everything else and scrambles order. Avoid.
How do you implement a focus trap for a modal?
When a dialog opens: move focus into it, keep Tab cycling within it, and Escape closes it.
function trapFocus(container: HTMLElement) {
const f = container.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), input, select, textarea, [tabindex]:not([tabindex="-1"])');
const first = f[0], last = f[f.length - 1];
container.addEventListener("keydown", (e) => {
if (e.key !== "Tab") return;
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
});
}In practice use the native <dialog> element (it traps focus and handles Escape) or a library (focus-trap, Radix Dialog) — they cover edge cases (dynamic content, no focusables, screen-reader virtual cursor). See Accessible Components (APG Patterns).
What is focus restoration and why does it matter?
When a modal/menu closes, focus must return to the element that opened it — otherwise it jumps to the top of the page and keyboard users lose their place:
function openDialog(trigger: HTMLElement) {
const previouslyFocused = document.activeElement as HTMLElement;
// ... show dialog, move focus in ...
// on close:
previouslyFocused?.focus();
}Same principle after deleting a row (focus the next row), closing a popover, etc. Lost focus is one of the most common SPA a11y bugs.
How do you handle focus on client-side route changes?
In an MPA, navigation resets focus to the top and screen readers announce the new page. In an SPA, the framework swaps content without that — so you must:
- move focus to the new page’s main heading or a focus target (
<h1 tabindex="-1">then.focus()), and/or - announce the change via a live region (“Navigated to Settings”).
Without this, a screen-reader user clicks a link and nothing is announced; focus is on a now-removed element. React Router/Next don’t do this automatically — handle it in a route-change effect.
What is the roving tabindex pattern?
For composite widgets (toolbar, tabs, menu, grid) you want one Tab stop for the whole group, then Arrow keys to move within it. Roving tabindex: the active item has tabindex="0", all others tabindex="-1"; arrow keys move the 0 and call .focus(). Alternative: aria-activedescendant (focus stays on the container, a property points at the active child) — better for very large lists/comboboxes.
How do you keep focus visible without ugly outlines everywhere?
Use :focus-visible, which shows the ring for keyboard focus but not mouse clicks:
button:focus { outline: none; }
button:focus-visible { outline: 2px solid #2563eb; outline-offset: 2px; }Never remove the outline globally with no replacement — that fails WCAG 2.4.7 and strands keyboard users.
Gotchas
display:none/visibility:hiddenremove from tab order (good for hidden content);opacity:0/ off-screen positioning do not — those leave invisible focusable traps. Hide properly.- A focus trap with no focusable children loops on nothing — focus the dialog container (
tabindex="-1") as a fallback. - Skip link — provide a “Skip to main content” link as the first focusable element so keyboard users bypass repeated nav (WCAG 2.4.1).
- Modal must block the background — set
aria-hidden/inerton the rest of the page (theinertattribute removes a subtree from focus + AT), or background elements remain Tab-reachable behind the overlay. - Don’t auto-focus aggressively — moving focus on every render or scrolling content into view unexpectedly disorients users.
- Mouse-only handlers (
onMouseEnteropening a menu with no keyboard equivalent) exclude keyboard users.
Related
Further reading
- MDN —
:focus-visible: https://developer.mozilla.org/en-US/docs/Web/CSS/:focus-visible - WAI-ARIA APG — Developing a Keyboard Interface: https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/
- MDN —
inert: https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/inert
Interview angle 4
- “What is the baseline keyboard requirement?” - everything operable without
a mouse, and a visible focus indicator. Use
:focus-visibleso the ring shows for keyboard users without appearing on every mouse click, and neveroutline: nonewithout providing a replacement. - “What does a correct modal do?” - traps focus inside itself, closes on
Escape, marks the background
inertso nothing behind it is reachable, and restores focus to the element that opened it on close. The restore is the step most implementations miss, and without it the user is dumped at the top of the document. - “A single-page app changes route. What has to happen?” - focus management,
manually. The router replaces the content and leaves focus wherever it was, so
a keyboard user is stranded and a screen reader user is not told anything
changed. Move focus to the new heading with
tabindex="-1", or announce the change in a live region. - “How do you handle a composite widget like a menu or a grid?” - one Tab
stop for the whole widget, then arrow keys inside it, via roving
tabindexoraria-activedescendant. Making every item tabbable means a user has to press Tab forty times to get past a menu.