Skip to content
Back to Quality Assurance

Keyboard Navigation

Every interactive element must be operable with the keyboard alone, with a visible focus indicator and logical order.

Why

Keyboard navigation is the foundation of accessibility — it is the only input for motor-impaired users (who cannot use a mouse), it is the fastest input for power users, and it is the prerequisite for every screen reader (which uses the keyboard to navigate). A button that cannot be focused, a modal that traps the tab key, a dropdown that requires a mouse hover — these are not minor inconveniences, they are complete barriers that make the app unusable for a significant population. WCAG 2.1.1 (Level A, the minimum legal requirement) states "all functionality is operable through a keyboard interface." This is not optional, and it is the first thing an accessibility auditor tests.

When

Every interactive element, on every page. Test keyboard navigation in every PR that adds or changes an interactive element, and as a full pass before launch.

How

1. Test the full flow with keyboard only — put the mouse away
   - Tab: move to the next interactive element
   - Shift+Tab: move to the previous
   - Enter/Space: activate buttons and links
   - Escape: close modals, menus, dropdowns
   - Arrow keys: navigate within composite widgets (tabs, menus, listboxes)

2. Verify the focus indicator is visible
   - Every focused element must have a visible outline or border
   - Do not use outline: none without a replacement (box-shadow, border)
   - Test with a high-contrast mode simulation — the indicator must still be visible
   - Use :focus-visible for keyboard-only indicators (avoids showing on mouse click)

3. Verify the focus order is logical
   - Tab should follow the visual reading order (top-to-bottom, left-to-right)
   - If the visual order is different from the DOM order, fix the DOM, not the tab order
   - Use tabindex="0" to make a non-native element focusable, but prefer native elements
   - Never use tabindex > 0 (positive values disrupt the natural tab order)

4. Manage focus for dynamic content
   - When a modal opens, move focus to the modal (not the trigger behind it)
   - When a modal closes, return focus to the trigger (so the user can continue)
   - Trap focus inside the modal while it is open (Tab cycles within the modal)
   - Use aria-live to announce dynamic content that changes without focus

5. Fix the common patterns
   - <div onClick> → <button onClick> (native button is keyboard-accessible by default)
   - <a href="#"> → <a href="/real-url"> (a real link is keyboard-accessible)
   - <select> with custom UI → use a library that supports keyboard (Radix, Headless UI)
   - Dropdown on hover → dropdown on click (keyboard cannot hover)

Example

// Bad: div with onClick — not focusable, not keyboard-accessible
<div onClick={handleClick} className="button-like">Click me</div>
// Screen reader: "Click me" (no role, no action available)
// Keyboard: cannot Tab to it, cannot activate it

// Good: native button — focusable, keyboard-accessible, correct role
<button onClick={handleClick}>Click me</button>
// Screen reader: "Click me, button"
// Keyboard: Tab to focus, Enter/Space to activate

// Focus management for a modal
import { useRef, useEffect } from "react";

function Modal({ isOpen, onClose, children }) {
  const modalRef = useRef(null);
  const triggerRef = useRef(null); // the button that opened the modal

  useEffect(() => {
    if (isOpen) {
      // Move focus into the modal when it opens
      modalRef.current?.focus();
    } else {
      // Return focus to the trigger when it closes
      triggerRef.current?.focus();
    }
  }, [isOpen]);

  if (!isOpen) return null;
  return (
    <div role="dialog" aria-modal="true" ref={modalRef} tabIndex={-1}>
      {children}
      <button onClick={onClose}>Close</button>
    </div>
  );
}

// Visible focus indicator (do not remove)
// CSS: keep a visible outline, or replace with an equivalent
.button:focus-visible {
  outline: 2px solid #0066cc;
  outline-offset: 2px;
}
/* Do NOT do this: */
.button { outline: none; } /* removes the keyboard indicator */

// Test: put the mouse away and Tab through the entire page
// 1. Can you reach every button, link, and input?
// 2. Is the focus indicator visible on each?
// 3. Can you activate buttons with Enter/Space?
// 4. Can you close modals with Escape?
// 5. Does focus return to the trigger after closing?

Common Problems

  • ⚠Using <div onClick> instead of <button> because the div is "easier to style" — the div is not focusable, not keyboard-activatable, and not announced as a button, excluding every keyboard and screen reader user.
  • ⚠Removing the focus outline (outline: none) for aesthetics without providing a replacement — the keyboard user cannot see where they are, and Tabbing through the page is a blind navigation.
  • ⚠Opening a modal without moving focus into it — the screen reader continues reading the page behind the modal, and the keyboard user Tabs through elements they cannot see.
  • ⚠Using positive tabindex (tabindex="1") to "fix" the tab order — this removes the element from the natural flow and creates a confusing, unpredictable navigation order.

Best Practices

  • ✓Use native HTML elements (button, a, input, select) whenever possible — they are keyboard-accessible and screen-reader-announced by default, and styling can be applied on top.
  • ✓Keep a visible focus indicator on every interactive element, using :focus-visible to show it only for keyboard navigation — removing it is the single most common a11y regression.
  • ✓Manage focus for dynamic content: move focus into modals on open, return to the trigger on close, and trap focus within the modal while open — this is the difference between a usable and unusable modal.
  • ✓Test every PR with a keyboard-only pass (put the mouse away) — this takes 2 minutes and catches the most common a11y barriers before they ship.