Keyboard Navigation Patterns for React and Web Apps
Unplug your mouse for five minutes and try using your app. Most web apps fall apart immediately because focus gets lost, trapped in the wrong container, or hidden behind an overlay. Keyboard navigation serves screen rea
Unplug your mouse for five minutes and try using your app. Most web apps fall apart immediately because focus gets lost, trapped in the wrong container, or hidden behind an overlay.
Keyboard navigation serves screen reader users, people with motor disabilities or temporary injuries, and power users who expect standard keys to work. Under WCAG 2.1 AA (criterion 2.1.1), all functionality must be operable via keyboard.
Here are the core rules, traps, and code patterns needed to handle focus properly in React.
The Core Interaction Rules
Before writing custom handlers, stick to native keyboard expectations:
-
Tab / Shift+Tab: Moves focus forward and backward through interactive elements (
<a href>,<button>, form controls, elements withtabindex="0"). - Enter: Activates links, buttons, and submits forms.
- Space: Activates buttons, toggles checkboxes, selects radios, opens select dropdowns. Never override Space scrolling when nothing interactive has focus.
- Escape: Closes overlays (modals, dropdowns, popovers) and returns focus to the trigger.
- Arrow Keys: Moves focus within a component (tabs, menus, grids). They should not move focus between unrelated components.
Focus Order and tabindex
Tab order follows visual reading order automatically when your DOM matches your visual layout.
A common pitfall is using CSS to reverse layout while leaving the DOM intact:
// Bad: Visual order doesn't match DOM order
<div className="flex flex-row-reverse">
<button>Third visually, first in DOM</button>
<button>Second visually, second in DOM</button>
<button>First visually, third in DOM</button>
</div>
// Good: DOM order matches visual order
<div className="flex">
<button>First</button>
<button>Second</button>
<button>Third</button>
</div>
Control tab order using these specific values:
-
tabindex="0": Adds an element into the natural tab order. -
tabindex="-1": Removes an element from tab order, but allows programmatic focus viaelement.focus(). -
tabindex="1"or higher: Avoid these entirely. Positive values override the natural tab order and create unexpected navigation jumps.
// Good: Programmatically focusable but not in tab order
<div ref={errorRef} tabIndex={-1} role="alert">
Something went wrong. Please try again.
</div>
// In error handler:
errorRef.current?.focus();
Focus Management Patterns
1. Modal Dialog Focus Trap
When a modal opens, focus must stay inside it until closed. Tab cycles between the first and last focusable elements, Escape closes it, and focus returns to the button that opened it.
import { useRef, useEffect } from 'react';
function Modal({ isOpen, onClose, triggerRef, children }) {
const modalRef = useRef(null);
useEffect(() => {
if (!isOpen) return;
const modal = modalRef.current;
const focusableElements = modal.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
// Focus the first element when modal opens
firstElement?.focus();
function handleKeyDown(e) {
if (e.key === 'Escape') {
onClose();
return;
}
if (e.key !== 'Tab') return;
// Trap focus inside modal
if (e.shiftKey) {
if (document.activeElement === firstElement) {
e.preventDefault();
lastElement?.focus();
}
} else {
if (document.activeElement === lastElement) {
e.preventDefault();
firstElement?.focus();
}
}
}
modal.addEventListener('keydown', handleKeyDown);
return () => modal.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose]);
// Return focus to trigger when modal closes
useEffect(() => {
if (!isOpen) {
triggerRef?.current?.focus();
}
}, [isOpen, triggerRef]);
if (!isOpen) return null;
return (
<div role="dialog" aria-modal="true" ref={modalRef}>
{children}
</div>
);
}
Libraries like Radix UI, Headless UI, and shadcn/ui implement this out of the box.
2. Focus Restoration
When closing a popover, dropdown, or drawer, restore focus to the trigger so the user does not get thrown back to the top of the body:
function Dropdown({ trigger, children }) {
const [isOpen, setIsOpen] = useState(false);
const triggerRef = useRef(null);
function close() {
setIsOpen(false);
// Restore focus to trigger
triggerRef.current?.focus();
}
return (
<>
<button ref={triggerRef} onClick={() => setIsOpen(!isOpen)}>
{trigger}
</button>
{isOpen && (
<div role="menu" onKeyDown={(e) => e.key === 'Escape' && close()}>
{children}
</div>
)}
</>
);
}
3. Dynamic Content Focus
Deciding when to move focus depends on what updated:
- Move focus: Validation errors requiring action, opened modals, or full-step changes in a multi-step wizard.
-
Do not move focus: Background toast notifications (use
aria-liveinstead) or newly loaded items in an infinite scroll list.
// Error: Move focus to error message
const errorRef = useRef(null);
function handleSubmit() {
const result = validate(formData);
if (!result.valid) {
setError(result.message);
// Move focus to error after render
requestAnimationFrame(() => errorRef.current?.focus());
}
}
<div ref={errorRef} tabIndex={-1} role="alert">
{error}
</div>
4. Skip Links
Skip links let keyboard users bypass repetitive top navigation bars and jump straight to the <main> tag. Keep it as the first focusable child in your root layout:
// In your root layout component
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-50 focus:px-4 focus:py-2 focus:bg-white focus:text-gray-900 focus:rounded-md focus:shadow-lg focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Skip to main content
</a>
{/* Later in the page */}
<main id="main-content" tabIndex={-1}>
{/* Page content */}
</main>
Arrow Key Navigation
Use the roving tabindex pattern for composite components like tabs, menus, and toolbars. The active item receives tabindex="0", while siblings receive tabindex="-1".
Roving Tabindex for Tabs
function TabList({ tabs, activeTab, onTabChange }) {
function handleKeyDown(e, index) {
let newIndex;
switch (e.key) {
case 'ArrowRight':
newIndex = (index + 1) % tabs.length;
break;
case 'ArrowLeft':
newIndex = (index - 1 + tabs.length) % tabs.length;
break;
case 'Home':
newIndex = 0;
break;
case 'End':
newIndex = tabs.length - 1;
break;
default:
return;
}
e.preventDefault();
onTabChange(newIndex);
}
return (
<div role="tablist">
{tabs.map((tab, index) => (
<button
key={tab.id}
role="tab"
aria-selected={index === activeTab}
tabIndex={index === activeTab ? 0 : -1}
onKeyDown={(e) => handleKeyDown(e, index)}
ref={(el) => {
if (index === activeTab) el?.focus();
}}
>
{tab.label}
</button>
))}
</div>
);
}
Menus and Dropdowns
Arrow keys handle vertical movement inside a dropdown menu:
// Key bindings for a dropdown menu
function handleMenuKeyDown(e, items, focusedIndex, setFocusedIndex) {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setFocusedIndex(Math.min(focusedIndex + 1, items.length - 1));
break;
case 'ArrowUp':
e.preventDefault();
setFocusedIndex(Math.max(focusedIndex - 1, 0));
break;
case 'Home':
e.preventDefault();
setFocusedIndex(0);
break;
case 'End':
e.preventDefault();
setFocusedIndex(items.length - 1);
break;
case 'Enter':
case ' ':
e.preventDefault();
items[focusedIndex]?.action();
break;
}
}
Component Interaction Matrix
| Component | Arrow Keys | Tab | Enter/Space | Escape |
|---|---|---|---|---|
| Tabs | Move between tabs | Move into/out of tab list | Activate tab | - |
| Menu | Move between items | Move into/out of menu | Activate item | Close menu |
| Accordion | Move between headers | Move into/out of accordion | Toggle panel | - |
| Listbox | Move between options | Move into/out of listbox | Select option | Close (if popup) |
| Tree | Up/Down between nodes, Left/Right expand/collapse | Move into/out of tree | Activate node | - |
| Grid/Table | Move between cells | Move into/out of grid | Activate cell | - |
Visible Focus Indicators
Removing default browser focus without an accessible replacement breaks navigation:
/* Bad: Removing focus with no replacement */
*:focus {
outline: none;
}
/* Good: Custom focus indicator */
*:focus-visible {
outline: 2px solid #685FD4;
outline-offset: 2px;
}
Use :focus-visible instead of :focus so keyboard users get a clear outline while mouse users do not see it on click:
/* Only shows for keyboard navigation */
button:focus-visible {
outline: 2px solid #685FD4;
outline-offset: 2px;
border-radius: 4px;
}
/* For inputs, always show focus (users need to know which field is active) */
input:focus {
outline: 2px solid #685FD4;
outline-offset: 2px;
}
Under WCAG 2.1 AA (criterion 2.4.7), focus rings should have at least a 2px outline thickness, maintain a 3:1 contrast ratio against the background, and stay visible in both light and dark modes.
AI Chat Interface Patterns
Chat interfaces need specific shortcuts:
- Prompt Input: Enter submits, Shift+Enter creates a newline, Arrow Up on an empty input recalls the previous prompt, and Escape clears the input.
-
Streaming Responses: Do not pull focus away from the textarea while text generates. Add
aria-live="polite"to the response wrapper so screen readers announce incoming text without interrupting user input. - Response Actions: Pressing Tab from the message moves to action buttons (copy, retry, feedback).
Common Mistakes to Avoid
1. Click Handlers on Non-Interactive Tags
// Bad: div with click handler but no keyboard support
<div onClick={handleAction}>Click me</div>
// Good: Use a button
<button onClick={handleAction}>Click me</button>
A <div> does not respond to Enter or Space and will not show up in the tab order.
2. Focus Inside Collapsed Elements
Collapsed accordions, inactive tabs, and closed menus must not keep focusable elements active in the DOM. Use hidden, display: none, or the inert attribute:
// Good: Hidden content is not focusable
<div hidden={!isOpen}>
<button>This button is not focusable when panel is closed</button>
</div>
3. Missing Focus After Deletion
When a user deletes an item from a list or table row, focus should land on the next item, the previous item, or the container. Never let focus reset back to the top <body> element.
This is a shorter version of a guide I wrote at Frontkit Studio. The full post has the complete walkthrough.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.