Frontend / Accessibility / 04_keyboard_and_focus.md

Keyboard and Focus Management

Updated 4 min read source
On this page6
  1. TL;DR
  2. In depth
  3. Gotchas / edge cases
  4. What a senior is expected to say
  5. Cross-references
  6. Further reading

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.

In depth

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.

ts
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:

ts
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:

css
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 4

  • “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