frontend / accessibility / 04_keyboard_and_focus.md

Keyboard and Focus Management

4 min read source

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:hidden remove 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 / inert on the rest of the page (the inert attribute 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 (onMouseEnter opening 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; never outline:none without 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. Positive tabindex is an anti-pattern.”

Cross-references

Further reading