React 97 ⚛️ Keyboard Navigation and Focus Management
Keyboard navigation is not an optional enhancement layered on top of a mouse-driven interface. It is the primary interaction model for screen reader users, power users, and anyone who cannot use a pointing device. In React, the challenge is that focus is a property of the DOM, not of component state. React’s declarative model renders elements, but moving focus requires an imperative call to element.focus(). Managing that interaction—deciding which element receives focus when a component mounts, when a modal opens, when an arrow key is pressed—is the subject of this chapter.
Key point: The tab sequence should include only one stop per composite widget. Once inside, arrow keys move focus among the widget’s internal elements. This pattern—roving tabindex—is the standard mechanism for toolbars, tabs, listboxes, and menus, and it exists because tabbing through every element of a complex widget is unusable.
Why keyboard navigation and focus management matter
The screen reader discovery problem. A screen reader user navigates by moving focus. Elements that are not focusable are not discoverable through keyboard navigation. A custom dropdown that renders <div> elements with click handlers but no tabIndex and no keyboard event handlers is invisible to a screen reader user, even if it works perfectly with a mouse . The WAI-ARIA Authoring Practices Guide is explicit that all interactive components must be reachable via the keyboard, either through the tab sequence or through a component that is in the tab sequence .
The focus trap problem. A modal dialog that does not trap focus allows the user to tab behind the overlay into the page content that is visually hidden. The user loses context, the screen reader announces content that is not visible, and the dialog’s purpose is defeated. Focus trapping is the mechanism that keeps keyboard navigation within the dialog while it is open, and returns focus to the triggering element when it closes .
The roving tabindex problem. A toolbar with twenty buttons should not require twenty tab presses to pass through. The standard pattern is to include only one button in the tab sequence at a time—the active or most recently focused one—and use arrow keys to move focus among the buttons within the toolbar. When the user tabs away and back, focus returns to the button they last interacted with, not the first one . This requires managing tabIndex dynamically, which is where React’s state and refs come into play.
The focus restoration problem. When a dialog closes, focus should return to the element that opened it. Without restoration, focus falls back to the <body>, and the keyboard user must navigate from the top of the page to find their place again. The pattern is to store a reference to document.activeElement before opening the dialog and call .focus() on it during cleanup .
a. Moving Focus Imperatively with Refs
React does not provide a declarative way to move focus. The autoFocus attribute exists on form elements, but it is limited and does not work for arbitrary elements or conditional rendering. The standard approach is a ref attached to the target element and a useEffect that calls .focus() when the condition for focusing is met.
import { useRef, useEffect } from 'react';
function SearchInput({ isOpen }) {
const inputRef = useRef(null);
useEffect(() => {
if (isOpen) {
// Move focus to the input when the search panel opens
inputRef.current?.focus();
}
}, [isOpen]);
return <input ref={inputRef} type="search" placeholder="Search..." />;
}
The optional chaining (?.) is not decorative. On the first render, inputRef.current is null because the DOM node has not been created yet. The effect runs after the DOM is committed, so by the time isOpen changes and the effect re-runs, the ref is populated. The guard prevents a runtime error in the edge case where the element is conditionally unmounted .
A callback ref is an alternative when the focus should happen at the moment the element mounts, rather than in response to a state change. The callback receives the DOM node as its argument and runs during the commit phase.
function AutoFocusInput() {
// Callback ref runs when the element is attached to the DOM
const handleRef = (element) => {
element?.focus();
};
return <input ref={handleRef} type="text" />;
}
The callback ref pattern is useful for focus on mount without a useEffect and without a dependency array. The trade-off is that the callback runs on every render where the ref is reattached, which can cause repeated focus calls if not guarded .
b. The Roving Tabindex Pattern
Roving tabindex is the standard mechanism for composite widgets. The rule is simple: exactly one element in the widget has tabIndex={0}, and all others have tabIndex={-1}. When the user presses an arrow key, focus moves to the adjacent element, and the tabIndex values are swapped: the newly focused element becomes 0 and the previously focused element becomes -1 .
import { useState, useRef } from 'react';
function Toolbar({ buttons }) {
const [activeIndex, setActiveIndex] = useState(0);
const buttonRefs = useRef([]);
const handleKeyDown = (event) => {
let newIndex = activeIndex;
if (event.key === 'ArrowRight') {
// Wrap from last to first
newIndex = (activeIndex + 1) % buttons.length;
} else if (event.key === 'ArrowLeft') {
// Wrap from first to last
newIndex = (activeIndex - 1 + buttons.length) % buttons.length;
} else if (event.key === 'Home') {
newIndex = 0;
} else if (event.key === 'End') {
newIndex = buttons.length - 1;
} else {
return;
}
event.preventDefault();
setActiveIndex(newIndex);
// Move focus after React re-renders the tabIndex values
buttonRefs.current[newIndex]?.focus();
};
return (
<div role="toolbar" aria-label="Text formatting" onKeyDown={handleKeyDown}>
{buttons.map((button, index) => (
<button
key={button.id}
ref={(el) => (buttonRefs.current[index] = el)}
tabIndex={index === activeIndex ? 0 : -1}
onClick={button.onClick}
>
{button.label}
</button>
))}
</div>
);
}
The preventDefault() on arrow keys is necessary because arrow keys scroll the page by default. Without it, pressing ArrowRight in the toolbar would both move focus and scroll the page horizontally, which is disorienting. The role="toolbar" and aria-label provide the semantics that tell the screen reader this is a toolbar and what it is for .
The same pattern applies to tabs, listboxes, and menus. The differences are in the ARIA roles and the selection behavior—tabs use aria-selected, menus use aria-activedescendant or roving tabindex, and listboxes use aria-selected with the option to use aria-activedescendant instead when focus stays on the listbox container .
c. Focus Trapping in Modals and Focus Restoration
A modal dialog must prevent focus from leaving the dialog while it is open. The standard implementation intercepts the Tab key, checks whether the focused element is the first or last focusable element in the dialog, and wraps focus to the opposite end. On close, focus returns to the element that triggered the dialog.
import { useEffect, useRef } from 'react';
function Modal({ isOpen, onClose, title, children }) {
const modalRef = useRef(null);
const triggerRef = useRef(null);
useEffect(() => {
if (!isOpen) return;
// Store the element that had focus before the modal opened
triggerRef.current = document.activeElement;
// Move focus into the modal
const focusable = modalRef.current?.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
focusable?.[0]?.focus();
const handleKeyDown = (event) => {
if (event.key === 'Escape') {
onClose();
return;
}
if (event.key !== 'Tab') return;
const elements = modalRef.current?.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (!elements?.length) return;
const first = elements[0];
const last = elements[elements.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
// Restore focus to the trigger when the modal closes
triggerRef.current?.focus();
};
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<div role="dialog" aria-modal="true" aria-labelledby="modal-title" ref={modalRef}>
<h2 id="modal-title">{title}</h2>
{children}
<button onClick={onClose}>Close</button>
</div>
);
}
The aria-modal="true" attribute tells assistive technology that content outside the dialog is inert. The focus trap and the aria-modal attribute are complementary: the attribute communicates the intent, and the trap enforces it for keyboard users . The native <dialog> element with .showModal() provides both automatically in modern browsers, which is why it is the recommended approach for new projects where browser support allows .
The query selector for focusable elements should exclude disabled elements and elements with tabIndex="-1". The selector above is a simplified version; a production implementation should also exclude hidden elements and elements with display: none or visibility: hidden. The @usefy/use-focus-trap package and similar hooks handle these edge cases, including nested traps and zero-focusable-element scenarios .
Complete Example Session
// ============================================
// PART 1: FOCUS ON MOUNT WITH A REF
// ============================================
import { useRef, useEffect } from 'react';
function SearchPanel({ isOpen }) {
const inputRef = useRef(null);
useEffect(() => {
if (isOpen) {
inputRef.current?.focus();
}
}, [isOpen]);
return <input ref={inputRef} type="search" />;
}
// ============================================
// PART 2: CALLBACK REF FOR IMMEDIATE FOCUS
// ============================================
function AutoFocusField() {
const handleRef = (element) => {
element?.focus();
};
return <input ref={handleRef} />;
}
// ============================================
// PART 3: ROVING TABINDEX FOR A TOOLBAR
// ============================================
import { useState, useRef } from 'react';
function Toolbar({ items }) {
const [activeIndex, setActiveIndex] = useState(0);
const refs = useRef([]);
const onKeyDown = (e) => {
let next = activeIndex;
if (e.key === 'ArrowRight') next = (activeIndex + 1) % items.length;
else if (e.key === 'ArrowLeft') next = (activeIndex - 1 + items.length) % items.length;
else if (e.key === 'Home') next = 0;
else if (e.key === 'End') next = items.length - 1;
else return;
e.preventDefault();
setActiveIndex(next);
refs.current[next]?.focus();
};
return (
<div role="toolbar" aria-label="Actions" onKeyDown={onKeyDown}>
{items.map((item, i) => (
<button
key={item.id}
ref={(el) => (refs.current[i] = el)}
tabIndex={i === activeIndex ? 0 : -1}
>
{item.label}
</button>
))}
</div>
);
}
// ============================================
// PART 4: FOCUS TRAP FOR A MODAL
// ============================================
function Modal({ isOpen, onClose, children }) {
const modalRef = useRef(null);
const triggerRef = useRef(null);
useEffect(() => {
if (!isOpen) return;
triggerRef.current = document.activeElement;
const selector = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
const focusable = modalRef.current?.querySelectorAll(selector);
focusable?.[0]?.focus();
const handleTab = (e) => {
if (e.key !== 'Tab') return;
const elements = modalRef.current?.querySelectorAll(selector);
if (!elements?.length) return;
const first = elements[0];
const last = elements[elements.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
};
document.addEventListener('keydown', handleTab);
return () => {
document.removeEventListener('keydown', handleTab);
triggerRef.current?.focus();
};
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<div ref={modalRef} role="dialog" aria-modal="true">
{children}
<button onClick={onClose}>Close</button>
</div>
);
}
// ============================================
// PART 5: ESCAPE KEY HANDLING
// ============================================
// Inside the modal effect, add:
// if (e.key === 'Escape') onClose();
// This is part of the same keydown listener
// ============================================
// PART 6: SKIP LINK FOR KEYBOARD USERS
// ============================================
// A skip link is the first focusable element on the page
// It allows keyboard users to jump past navigation
function SkipLink() {
return (
<a href="#main-content" className="skip-link">
Skip to main content
</a>
);
}
// CSS: position absolute, top -40px; on focus, top 0
// ============================================
// PART 7: ARIA-ACTIVEDESCENDANT ALTERNATIVE
// ============================================
// When focus stays on the container (combobox, searchbox)
// Use aria-activedescendant instead of roving tabindex
<div
role="listbox"
tabIndex={0}
aria-activedescendant={activeOptionId}
>
{options.map(opt => (
<div key={opt.id} id={opt.id} role="option">{opt.label}</div>
))}
</div>
// ============================================
// PART 8: DISABLED ELEMENTS IN COMPOSITE WIDGETS
// ============================================
// Use aria-disabled="true" to keep element focusable
// but announce it as disabled
<button aria-disabled="true" tabIndex={0}>
Unavailable action
</button>
// The element is discoverable by keyboard and screen reader
// but does not perform an action
// ============================================
// PART 9: FOCUS RESTORATION ON UNMOUNT
// ============================================
// The cleanup function in useEffect restores focus
// when the modal unmounts or isOpen becomes false
return () => {
triggerRef.current?.focus();
};
// ============================================
// PART 10: USING A FOCUS TRAP LIBRARY
// ============================================
// For production, a library handles edge cases:
// import { useFocusTrap } from '@usefy/use-focus-trap';
// const ref = useFocusTrap(isOpen, { onEscape: onClose });
// Handles nested traps, zero focusable elements, SSR
The ten parts move from basic focus on mount through the roving tabindex pattern and the modal focus trap. Part 7 shows the alternative to roving tabindex for comboboxes and searchboxes, where focus stays on the input and aria-activedescendant tracks the active option. Part 8 addresses the discoverability trade-off for disabled elements in composite widgets.
Quick Reference
Focus Management Patterns
| Pattern | Use Case | Mechanism |
|---|---|---|
Ref + useEffect | Focus on state change | ref.current.focus() |
| Callback ref | Focus on mount | Callback receives element |
| Roving tabindex | Composite widgets (toolbar, tabs) | One tabIndex=0, rest -1 |
aria-activedescendant | Combobox, searchbox | Focus stays on container |
| Focus trap | Modal, dialog | Intercept Tab key |
Roving Tabindex Rules
| Rule | Detail |
|---|---|
| One tab stop | Exactly one element with tabIndex={0} |
| Arrow keys move focus | Left/Right or Up/Down depending on orientation |
| Swap tabIndex | Focused element becomes 0, previous becomes -1 |
| Home/End | Optional, move to first/last element |
| Wrap | Optional, but recommended for toolbars |
Focus Trap Checklist
| Check | Purpose |
|---|---|
Store document.activeElement on open | Enables focus restoration |
| Focus first element on open | Moves user into dialog |
Intercept Tab at boundaries | Prevents focus escaping |
Handle Escape | Standard close mechanism |
| Restore focus on close | Returns user to trigger |
aria-modal="true" | Hides background from AT |
Best Practices
✅ Do This:
// Guard ref access before focusing
inputRef.current?.focus(); // ✅
// Use roving tabindex for toolbars and tabs
tabIndex={index === activeIndex ? 0 : -1} // ✅
// Store the trigger element before opening a modal
triggerRef.current = document.activeElement; // ✅
// Prevent default on arrow keys in composite widgets
event.preventDefault(); // ✅
// Use aria-disabled instead of disabled for focusable disabled elements
aria-disabled="true" // ✅
❌ Don’t Do This:
// Focus an element without checking the ref
inputRef.current.focus(); // May be null on first render // ❌
// Put every toolbar button in the tab sequence
<button tabIndex={0}> // All buttons, no arrow navigation // ❌
// Forget to restore focus when a modal closes
// Focus falls to body, user loses place // ❌
// Use positive tabIndex values
tabIndex={1} // Disrupts natural DOM order // ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Focus does not move on open | useEffect dependency missing | Include isOpen in deps |
| Focus moves but is lost on re-render | Ref attached to conditional element | Ensure element exists before focusing |
| Arrow keys scroll the page | No preventDefault | Call event.preventDefault() |
| Modal focus escapes | No trap or incomplete selector | Intercept Tab at boundaries |
| Focus not restored on close | Cleanup missing .focus() | Store trigger, restore in cleanup |
| Disabled element not discoverable | Used disabled attribute | Use aria-disabled for focusable disabled |
Real-World Examples
1. Focus Input on Panel Open
useEffect(() => { if (isOpen) inputRef.current?.focus(); }, [isOpen]);
2. Callback Ref for Mount Focus
const handleRef = (el) => el?.focus();
3. Roving Tabindex State
const [active, setActive] = useState(0);
tabIndex={i === active ? 0 : -1}
4. Arrow Key Handler
if (e.key === 'ArrowRight') { e.preventDefault(); moveNext(); }
5. Modal Trigger Storage
triggerRef.current = document.activeElement;
6. Focus Trap Boundary
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
7. Focus Restoration
return () => triggerRef.current?.focus();
8. Skip Link
<a href="#main" className="skip-link">Skip to content</a>
9. Aria-Activedescendant
<div role="listbox" aria-activedescendant={activeId} tabIndex={0}>
10. Disabled but Focusable
<button aria-disabled="true" tabIndex={0}>Unavailable</button>
Visual
Roving Tabindex Flow
┌──────────────────────────────────────────────────────────────┐
│ TOOLBAR WITH THREE BUTTONS │
│ │
│ Initial state (button 1 active): │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ Button 1│ │ Button 2│ │ Button 3│ │
│ │ tab=0 │ │ tab=-1 │ │ tab=-1 │ │
│ │ FOCUSED │ │ │ │ │ │
│ └─────────┘ └─────────┘ └─────────┘ │
│ │
│ User presses ArrowRight: │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ Button 1│ │ Button 2│ │ Button 3│ │
│ │ tab=-1 │ │ tab=0 │ │ tab=-1 │ │
│ │ │ │ FOCUSED │ │ │ │
│ └─────────┘ └─────────┘ └─────────┘ │
│ │
│ User presses Tab to leave and Tab to return: │
│ Focus returns to Button 2 (the last active), not Button 1 │
│ │
│ Only one element is in the tab sequence at any time. │
│ │
└──────────────────────────────────────────────────────────────┘
Focus Trap in Modal
┌──────────────────────────────────────────────────────────────┐
│ MODAL OPENED │
│ │
│ ┌─────────────────────────────────────────┐ │
│ │ DIALOG │ │
│ │ │ │
│ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │
│ │ │ Input │ │ Cancel │ │ Confirm │ │ │
│ │ │ FIRST │ │ │ │ LAST │ │ │
│ │ └─────────┘ └─────────┘ └─────────┘ │ │
│ │ │ │
│ │ Tab on LAST → wraps to FIRST │ │
│ │ Shift+Tab on FIRST → wraps to LAST │ │
│ │ │ │
│ └─────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────┐ │
│ │ PAGE CONTENT (INERT) │ │
│ │ Tab cannot reach these elements │ │
│ └─────────────────────────────────────────┘ │
│ │
│ aria-modal="true" communicates the trap to AT. │
│ On close: focus returns to the trigger button. │
│ │
└──────────────────────────────────────────────────────────────┘
Focus Restoration Flow
┌──────────────────────────────────────────────────────────────┐
│ BEFORE MODAL OPENS │
│ │
│ ┌─────────────────┐ │
│ │ Button │ document.activeElement │
│ │ "Open Settings" │ triggerRef.current = this │
│ └────────┬────────┘ │
│ │ click │
│ ▼ │
│ ┌─────────────────┐ │
│ │ MODAL OPENS │ Focus moves to first element │
│ │ │ (input or close button) │
│ └────────┬────────┘ │
│ │ user navigates, closes │
│ ▼ │
│ ┌─────────────────┐ │
│ │ MODAL CLOSES │ Cleanup: triggerRef.current.focus() │
│ └────────┬────────┘ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Button │ Focus restored │
│ │ "Open Settings" │ Keyboard user continues from here │
│ └─────────────────┘ │
│ │
│ Without restoration, focus falls to <body>. │
│ │
└──────────────────────────────────────────────────────────────┘
Aria-Activedescendant vs. Roving Tabindex
┌──────────────────────────────────────────────────────────────┐
│ ROVING TABINDEX ARIA-ACTIVEDESCENDANT │
│ │
│ Focus moves to each Focus stays on the container │
│ option element (input, listbox) │
│ │
│ ┌─────────┐ ┌─────────────────┐ │
│ │ Option 1│ ← focus │ Combobox │ ← focus │
│ └─────────┘ │ aria-activedesc │ │
│ ┌─────────┐ └────────┬────────┘ │
│ │ Option 2│ │ │
│ └─────────┘ ▼ │
│ ┌─────────┐ ┌─────────────────┐ │
│ │ Option 3│ │ Option 1 │ │
│ └─────────┘ │ Option 2 ← active│ │
│ │ Option 3 │ │
│ Use for toolbars, └─────────────────┘ │
│ tabs, menus Use for comboboxes, │
│ searchboxes, virtualized │
│ lists │
│ │
└──────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Focus API | ref.current.focus() |
| Focus on mount | Callback ref or useEffect |
| Roving tabindex | One tabIndex=0, rest -1 |
| Arrow key handling | preventDefault() + move focus + swap tabIndex |
| Focus trap | Intercept Tab at first/last element |
| Focus restoration | Store document.activeElement, restore on cleanup |
aria-modal | Communicates inert background to AT |
aria-activedescendant | Alternative when focus stays on container |
| Disabled focusable | aria-disabled="true" instead of disabled |
Key takeaways:
- Focus is imperative; React state is declarative. Moving focus requires a ref and a
.focus()call. The effect or callback ref is the bridge between React’s render cycle and the DOM’s focus state. - The tab sequence should have one stop per composite widget. Roving tabindex is the mechanism: one element has
tabIndex={0}, the rest havetabIndex={-1}, and arrow keys move focus and swap the values. This is the standard for toolbars, tabs, listboxes, and menus . - Modals must trap focus. Without a trap, keyboard users can tab behind the overlay into content they cannot see. The trap intercepts
Tabat the boundaries and wraps focus to the opposite end.aria-modal="true"communicates the same intent to assistive technology . - Focus restoration is part of the modal lifecycle. Storing
document.activeElementbefore the modal opens and calling.focus()on it during cleanup returns the user to their place. Without it, focus falls to the body and the user must navigate from the top . aria-activedescendantis the alternative for comboboxes and virtualized lists. When focus must stay on an input or container, the active option is tracked witharia-activedescendantrather than moving DOM focus to each option .- The native
<dialog>element provides focus trapping and Escape handling. For new projects with modern browser support,.showModal()is the recommended approach because it implements the trap, the backdrop, and the ARIA role without custom code .
Remember: Keyboard navigation is not a feature that can be added after the component works with a mouse. It is a property of the component’s structure: the tab sequence, the focus order, the key handlers, and the focus restoration. In React, these are managed through refs and effects, not through JSX attributes alone. The roving tabindex pattern exists because tabbing through every element of a complex widget is unusable; the focus trap exists because tabbing behind a modal is disorienting. The implementation details—preventDefault, tabIndex swapping, trigger storage, cleanup restoration—are the mechanics that make the accessibility intent real.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!