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
| Value | Behavior |
|---|---|
| Not set | Element 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
| Key | Action |
|---|---|
Tab | Move focus to next focusable element |
Shift + Tab | Move focus to previous focusable element |
Enter | Activate link or button |
Space | Activate button, toggle checkbox |
Escape | Close modal, dismiss popup, cancel operation |
Arrow keys | Navigate within composite widgets (tabs, menus, grids) |
Home / End | Move 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 itemFocus 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
- All interactive elements must be reachable and operable by keyboard
- Focus order must match the visual layout order
- Always provide visible focus indicators (use
:focus-visible) - Trap focus inside modals and restore focus on close
- Use roving tabIndex for composite widgets (tabs, toolbars, menus)
- Manage focus after dynamic content changes (deletions, route changes)
- Never use
tabindexvalues greater than 0 - Use the native
<dialog>element when possible for built-in focus management