Steven's Knowledge
Accessibility

Keyboard & Focus

Keyboard navigation, focus management, focus trapping, and roving tabindex patterns

Keyboard & Focus

All interactive content must be operable with a keyboard alone. This covers navigation patterns, focus management, and common keyboard interaction models.

Keyboard Navigation Basics

Default Tab Order

The tab order follows the DOM source order. Only natively interactive elements receive focus by default.

Natively Focusable Elements
├── <a href="...">
├── <button>
├── <input> / <textarea> / <select>
├── <details> / <summary>
├── <dialog> (when open)
└── Elements with tabindex="0"

tabIndex Values

ValueBehavior
Not setElement follows natural focusability (interactive elements focusable)
tabindex="0"Add to tab order at DOM position
tabindex="-1"Focusable programmatically, removed from tab order
tabindex="1+"Avoid — forces element first in tab order, causes confusion
// Make a custom element focusable and keyboard-interactive
<div
  role="button"
  tabIndex={0}
  onClick={handleClick}
  onKeyDown={(e) => {
    if (e.key === 'Enter' || e.key === ' ') {
      e.preventDefault();
      handleClick();
    }
  }}
>
  Custom Button
</div>

// Programmatically focusable but not in tab order
<div tabIndex={-1} ref={sectionRef}>
  Focus target for skip links or focus management
</div>

Expected Keyboard Interactions

Standard Keys

KeyAction
TabMove focus to next focusable element
Shift + TabMove focus to previous focusable element
EnterActivate link or button
SpaceActivate button, toggle checkbox
EscapeClose modal, dismiss popup, cancel operation
Arrow keysNavigate within composite widgets (tabs, menus, grids)
Home / EndMove to first/last item in list

Widget-Specific Patterns (WAI-ARIA APG)

Keyboard Patterns by Widget
├── Tabs
│   ├── Arrow Left/Right → Move between tabs
│   ├── Home/End → First/last tab
│   └── Tab → Move to tab panel content
├── Menu
│   ├── Arrow Up/Down → Navigate items
│   ├── Enter/Space → Activate item
│   ├── Escape → Close menu
│   └── Home/End → First/last item
├── Dialog
│   ├── Tab → Cycle through focusable elements inside
│   └── Escape → Close dialog
├── Accordion
│   ├── Enter/Space → Toggle section
│   └── Arrow Up/Down → Move between headers
└── Tree View
    ├── Arrow Up/Down → Navigate items
    ├── Arrow Right → Expand node / move to child
    ├── Arrow Left → Collapse node / move to parent
    └── Enter → Activate item

Focus Management

Programmatic Focus

// Focus management on route change (SPA)
function useFocusOnNavigate() {
  const headingRef = useRef<HTMLHeadingElement>(null);
  const pathname = usePathname();

  useEffect(() => {
    headingRef.current?.focus();
  }, [pathname]);

  return headingRef;
}

// Usage
function Page({ title, children }: PageProps) {
  const headingRef = useFocusOnNavigate();

  return (
    <main>
      <h1 ref={headingRef} tabIndex={-1}>
        {title}
      </h1>
      {children}
    </main>
  );
}

Focus After Dynamic Content

// Focus after deleting an item from a list
function ItemList({ items, onDelete }: ItemListProps) {
  const listRef = useRef<HTMLUListElement>(null);

  const handleDelete = (id: string) => {
    onDelete(id);
    // Focus the list container or next item after deletion
    requestAnimationFrame(() => {
      const nextItem = listRef.current?.querySelector<HTMLElement>('[data-item]');
      nextItem?.focus();
    });
  };

  return (
    <ul ref={listRef}>
      {items.map((item) => (
        <li key={item.id} data-item tabIndex={-1}>
          {item.name}
          <button onClick={() => handleDelete(item.id)}>Delete</button>
        </li>
      ))}
    </ul>
  );
}

Focus Trapping

Modals and dialogs must trap focus — keyboard users should not be able to tab outside the dialog while it's open.

Focus Trap Implementation

function useFocusTrap(isActive: boolean) {
  const containerRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (!isActive) return;

    const container = containerRef.current;
    if (!container) return;

    const focusableSelector =
      'a[href], button:not([disabled]), input:not([disabled]), ' +
      'select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';

    const getFocusableElements = () =>
      Array.from(container.querySelectorAll<HTMLElement>(focusableSelector));

    const handleKeyDown = (e: KeyboardEvent) => {
      if (e.key !== 'Tab') return;

      const focusable = getFocusableElements();
      const first = focusable[0];
      const last = focusable[focusable.length - 1];

      if (e.shiftKey && document.activeElement === first) {
        e.preventDefault();
        last?.focus();
      } else if (!e.shiftKey && document.activeElement === last) {
        e.preventDefault();
        first?.focus();
      }
    };

    // Focus first element
    const focusable = getFocusableElements();
    focusable[0]?.focus();

    container.addEventListener('keydown', handleKeyDown);
    return () => container.removeEventListener('keydown', handleKeyDown);
  }, [isActive]);

  return containerRef;
}

// Usage in modal
function Modal({ isOpen, onClose, title, children }: ModalProps) {
  const trapRef = useFocusTrap(isOpen);
  const titleId = useId();

  useEffect(() => {
    if (!isOpen) return;
    const handleEsc = (e: KeyboardEvent) => {
      if (e.key === 'Escape') onClose();
    };
    document.addEventListener('keydown', handleEsc);
    return () => document.removeEventListener('keydown', handleEsc);
  }, [isOpen, onClose]);

  if (!isOpen) return null;

  return createPortal(
    <div className="modal-overlay" onClick={(e) => {
      if (e.target === e.currentTarget) onClose();
    }}>
      <div
        ref={trapRef}
        role="dialog"
        aria-modal="true"
        aria-labelledby={titleId}
      >
        <h2 id={titleId}>{title}</h2>
        {children}
        <button onClick={onClose}>Close</button>
      </div>
    </div>,
    document.body
  );
}

Using the Native <dialog> Element

// <dialog> provides built-in focus trapping and Escape handling
function NativeDialog({ isOpen, onClose, title, children }: DialogProps) {
  const dialogRef = useRef<HTMLDialogElement>(null);

  useEffect(() => {
    const dialog = dialogRef.current;
    if (!dialog) return;

    if (isOpen) {
      dialog.showModal(); // Traps focus automatically
    } else {
      dialog.close();
    }
  }, [isOpen]);

  return (
    <dialog ref={dialogRef} onClose={onClose}>
      <h2>{title}</h2>
      {children}
      <button onClick={onClose}>Close</button>
    </dialog>
  );
}

Roving tabIndex

For composite widgets (tabs, toolbars, menus), use roving tabIndex so only one item in the group is in the tab order at a time.

function Toolbar({ items }: ToolbarProps) {
  const [activeIndex, setActiveIndex] = useState(0);

  const handleKeyDown = (e: React.KeyboardEvent, index: number) => {
    let newIndex = index;

    switch (e.key) {
      case 'ArrowRight':
        newIndex = (index + 1) % items.length;
        break;
      case 'ArrowLeft':
        newIndex = (index - 1 + items.length) % items.length;
        break;
      case 'Home':
        newIndex = 0;
        break;
      case 'End':
        newIndex = items.length - 1;
        break;
      default:
        return;
    }

    e.preventDefault();
    setActiveIndex(newIndex);
  };

  return (
    <div role="toolbar" aria-label="Text formatting">
      {items.map((item, index) => (
        <button
          key={item.id}
          tabIndex={index === activeIndex ? 0 : -1}
          ref={(el) => {
            if (index === activeIndex) el?.focus();
          }}
          onKeyDown={(e) => handleKeyDown(e, index)}
          aria-pressed={item.active}
        >
          {item.label}
        </button>
      ))}
    </div>
  );
}
Tab Order with Roving tabIndex:

[Previous element] → Tab → [Active toolbar button] → Tab → [Next element]
                              ↕ Arrow keys
                    [Other toolbar buttons]

Focus Indicators

Visible Focus Styles

/* Remove default and add custom focus styles */
:focus {
  outline: none;
}

:focus-visible {
  outline: 2px solid #2563eb;
  outline-offset: 2px;
  border-radius: 2px;
}

/* focus-visible only shows for keyboard navigation, not mouse clicks */
button:focus-visible {
  outline: 2px solid #2563eb;
  outline-offset: 2px;
  box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.2);
}

/* Ensure focus indicator has sufficient contrast */
/* WCAG 2.4.7: Focus visible (AA) */
/* WCAG 2.4.13: Focus appearance (AAA) — 2px outline, 3:1 contrast */

Never remove focus indicators without replacing them. outline: none without a visible alternative makes the page unusable for keyboard users. Use :focus-visible to show indicators only during keyboard navigation.

Focus Not Obscured (WCAG 2.4.11)

/* Ensure sticky headers don't cover focused elements */
header {
  position: sticky;
  top: 0;
  z-index: 10;
}

/* Add scroll margin so focused elements aren't hidden behind sticky header */
:target,
[tabindex]:focus {
  scroll-margin-top: 80px; /* Height of sticky header */
}

Best Practices

Keyboard & Focus Guidelines

  1. All interactive elements must be reachable and operable by keyboard
  2. Focus order must match the visual layout order
  3. Always provide visible focus indicators (use :focus-visible)
  4. Trap focus inside modals and restore focus on close
  5. Use roving tabIndex for composite widgets (tabs, toolbars, menus)
  6. Manage focus after dynamic content changes (deletions, route changes)
  7. Never use tabindex values greater than 0
  8. Use the native <dialog> element when possible for built-in focus management

On this page