Keyboard and Focus Management
TL;DR
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.
Interview Q&A
Q: What’s the baseline for keyboard support?
A: 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 (02_semantic_html_and_landmarks.md).
Q: How does tabindex work?
A:
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.
Q: How do you implement a focus trap for a modal?
A: 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 06_accessible_components.md.
Q: What is focus restoration and why does it matter?
A: 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.
Q: How do you handle focus on client-side route changes?
A: 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.
Q: What is the roving tabindex pattern?
A: 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.
Q: How do you keep focus visible without ugly outlines everywhere?
A: 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 / edge cases
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.
What a senior is expected to say
- “Everything keyboard-operable with a visible focus ring via
:focus-visible; neveroutline:nonewithout a replacement.” - “Modals trap focus, close on Escape, and restore focus to the trigger on close; I mark the background
inert.” - “SPA route changes need manual focus management — move focus to the new
<h1 tabindex=-1>and/or announce via a live region; the router won’t do it.” - “Composite widgets use one Tab stop + arrow keys via roving tabindex or
aria-activedescendant. Positivetabindexis an anti-pattern.”
Cross-references
- Native elements that give keyboard support free: 02_semantic_html_and_landmarks.md
- ARIA widgets that require these handlers: 03_aria.md, 06_accessible_components.md
- Focus-ring styling (
:focus-visible): ../02_css/ - React focus management on navigation: ../05_react/, ../19_rendering_modes/
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