Design and implement keyboard navigation patterns that follow the ARIA Authoring Practices Guide — tab order, arrow key navigation, focus management, and skip links.
Design and implement keyboard navigation patterns that follow the ARIA Authoring Practices Guide — tab order, arrow key navigation, focus management, and skip links.
Keyboard navigation follows two distinct patterns that users have learned to expect. Tab key moves between focusable elements — buttons, links, inputs, and custom widgets. Each Tab press moves to the next focusable element, Shift+Tab moves backwards. Arrow keys navigate within a widget — a menu, radio group, tab panel, or listbox uses arrow keys to move between items, with only one item in the group in the Tab sequence at a time (this is the roving tabIndex pattern).
The ARIA Authoring Practices Guide (APG) at w3.org/WAI/ARIA/apg defines the keyboard pattern for every widget type. Always consult the APG before implementing a custom interactive component. Deviating from APG patterns confuses screen reader users who have learned the standard patterns.
1Tab key pattern (across widgets):
2[Link] → [Button] → [Input] → [Select] → [Radio Group] → [Button]
3
4Arrow key pattern (within a widget):
5Radio Group: [•] Option A ← → [•] Option B ← → [•] Option C
6Menu: [Item 1] ↑↓ [Item 2] ↑↓ [Item 3]
7Tab panel: [Tab 1] ↑↓← → [Tab 2] ↑↓← → [Tab 3]The roving tabIndex pattern keeps exactly one item in a group in the Tab sequence at any time. This prevents Tab from traversing every item in a large list or menu:
1import { useRef, useState } from 'react';
2
3function RadioGroup({ options, name }: { options: string[]; name: string }) {
4 const [activeIndex, setActiveIndex] = useState(0);
5 const refs = useRef<(HTMLInputElement | null)[]>([]);
6
7 const handleKeyDown = (e: React.KeyboardEvent, index: number) => {
8 let next = index;
9 if (e.key === 'ArrowDown' || e.key === 'ArrowRight') {
10 next = (index + 1) % options.length;
11 } else if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') {
12 next = (index - 1 + options.length) % options.length;
13 } else {
14 return;
15 }
16 e.preventDefault();
17 setActiveIndex(next);
18 refs.current[next]?.focus();
19 };
20
21 return (
22 <div role="radiogroup" aria-labelledby={`${name}-label`}>
23 {options.map((option, i) => (
24 <label key={option}>
25 <input
26 ref={el => (refs.current[i] = el)}
27 type="radio"
28 name={name}
29 value={option}
30 // Only the active item is in Tab order (tabIndex=0)
31 // Others are tabIndex=-1 (focusable but not in Tab sequence)
32 tabIndex={i === activeIndex ? 0 : -1}
33 onKeyDown={e => handleKeyDown(e, i)}
34 onChange={() => setActiveIndex(i)}
35 />
36 {option}
37 </label>
38 ))}
39 </div>
40 );
41}When UI changes dynamically (modal opens, alert appears, navigation changes page), focus must be managed explicitly or keyboard users lose their position:
1import { useEffect, useRef } from 'react';
2
3function Dialog({ isOpen, onClose, children }: DialogProps) {
4 const dialogRef = useRef<HTMLDivElement>(null);
5 const triggerRef = useRef<HTMLElement | null>(null);
6
7 useEffect(() => {
8 if (isOpen) {
9 // Store reference to the element that triggered the dialog
10 triggerRef.current = document.activeElement as HTMLElement;
11 // Move focus into the dialog (first focusable element)
12 const firstFocusable = dialogRef.current?.querySelector<HTMLElement>(
13 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
14 );
15 firstFocusable?.focus();
16 } else {
17 // Return focus to the trigger when dialog closes
18 triggerRef.current?.focus();
19 }
20 }, [isOpen]);
21
22 if (!isOpen) return null;
23
24 return (
25 <div
26 ref={dialogRef}
27 role="dialog"
28 aria-modal="true"
29 aria-labelledby="dialog-title"
30 >
31 <h2 id="dialog-title">Dialog Title</h2>
32 {children}
33 <button onClick={onClose}>Close</button>
34 </div>
35 );
36}Focus rules for common scenarios: modal opens → focus moves to first focusable element inside; modal closes → focus returns to the trigger button; route changes in SPA → focus moves to the main heading or page top; search results update → focus moves to the results count or first result; inline error appears → focus moves to the error message or the input with the error.
WCAG 2.4.1 (Bypass Blocks, Level A) requires a mechanism to skip repeated blocks like navigation menus. Skip links are the standard solution — visible links at the very top of the page that let keyboard users jump directly to main content:
1<!-- At the very top of body, before navigation -->
2<a href="#main-content" class="skip-link">Skip to main content</a>
3<a href="#main-nav" class="skip-link">Skip to navigation</a>
4
5<nav id="main-nav">...navigation...</nav>
6
7<main id="main-content" tabindex="-1">
8 <!-- tabindex=-1 makes non-interactive main element focusable for skip link -->
9 <h1>Page Title</h1>
10</main>1.skip-link {
2 position: absolute;
3 /* Offscreen but still in document flow — visible to screen readers */
4 left: -9999px;
5 width: 1px;
6 height: 1px;
7 overflow: hidden;
8}
9
10/* Reveal when focused — keyboard users see it, mouse users do not */
11.skip-link:focus {
12 position: fixed;
13 top: 1rem;
14 left: 1rem;
15 width: auto;
16 height: auto;
17 overflow: visible;
18 background: var(--color-interactive-primary);
19 color: white;
20 padding: 0.75rem 1.5rem;
21 border-radius: 4px;
22 font-weight: 600;
23 z-index: 9999;
24 text-decoration: none;
25}The tabIndex="-1" on main is necessary because focus cannot be placed on non-interactive elements by default. Without it, the skip link anchor navigates to the element visually but does not move keyboard focus there.
Tab order should match visual reading order (top to bottom, left to right in LTR languages). When visual layout and DOM order diverge (due to CSS Flexbox order or position absolute), keyboard navigation becomes confusing. Always design with DOM order matching visual order.
1/* PROBLEM: Visual order differs from DOM order */
2.card-grid {
3 display: flex;
4}
5.card:nth-child(2) {
6 order: -1; /* Shows second card first visually */
7 /* But Tab still visits in DOM order (card 1 first) */
8 /* Keyboard order ≠ visual order — WCAG 1.3.2 violation */
9}
10
11/* SOLUTION: Reorder in DOM, not with CSS order */
12<!-- Move the card to be first in the HTML -->tabIndex dos and donts:
1// DO: Use tabIndex=0 to add non-interactive elements to Tab order
2<div role="button" tabIndex={0} onClick={handleClick}>Custom button</div>
3
4// DO: Use tabIndex=-1 to make elements programmatically focusable but not in Tab sequence
5<div ref={dialogRef} tabIndex={-1} role="dialog">Modal content</div>
6
7// DON'T: Use positive tabIndex values — they override natural Tab order
8// and create maintenance nightmares
9<input tabIndex={3} /> // Never do this — use DOM ordering instead
10<button tabIndex={1} /> // Never do thisPositive tabIndex values override the natural document order for ALL focusable elements on the page, creating a non-obvious and hard-to-maintain Tab sequence. The only valid tabIndex values are 0 (natural order) and -1 (remove from Tab sequence).
Open your most complex interactive page and navigate it using only the Tab, Shift+Tab, Enter, Space, Escape, and Arrow keys. Note every place where focus becomes invisible, gets trapped, or jumps to an unexpected location. Those are your keyboard accessibility bugs.
Your progress is saved in this browser, on this device only.