Keyboard accessibility ensures that every feature on your website can be reached and operated using only a keyboard -- no mouse or touch input required. According to the CDC, approximately 26% of adults in the United States have some form of disability, and roughly 7.5% have a motor disability that affects their ability to use a pointing device. These numbers represent tens of millions of people who may depend entirely on keyboard navigation to interact with the web.
The reach of keyboard accessibility extends far beyond users with permanent disabilities. Power users prefer keyboard shortcuts for speed and efficiency. Screen reader users rely on the keyboard as their primary navigation interface since screen readers are inherently keyboard-driven tools. People with temporary injuries -- a broken arm, recovering from surgery, or dealing with repetitive strain injury -- also depend on keyboard access. By building keyboard-accessible interfaces, you serve all of these groups simultaneously.
The Web Content Accessibility Guidelines (WCAG) encode these requirements into specific, testable success criteria. The four most directly relevant are: 2.1.1 Keyboard (all functionality must be operable via keyboard), 2.1.2 No Keyboard Trap (focus can always be moved away from any component), 2.4.3 Focus Order (navigation sequence must be logical and meaningful), and 2.4.7 Focus Visible (the keyboard focus indicator must always be clearly visible). This guide covers all four in depth, with interactive demos and production-ready code examples.
2.1.1
Keyboard
2.1.2
No Keyboard Trap
2.4.3
Focus Order
2.4.7
Focus Visible
When a user presses the Tab key, the browser moves focus to the next focusable element in the DOM order -- the order elements appear in your HTML source. This is why your source order should match your visual layout. Using CSS to visually rearrange elements (via flexbox order, grid positioning, or absolute positioning) without matching the DOM order creates a disorienting experience for keyboard users.
The tabindex attribute controls how elements participate in the tab sequence. Use tabindex="0" to add a non-interactive element to the natural tab order. Use tabindex="-1" to make an element focusable via JavaScript but not via Tab. Never use positive values like tabindex="5" -- they override the natural DOM order and create an unpredictable, maintenance-nightmare tab sequence.
0 and -1.Semantic HTML elements like <button>, <a href>, and <input> are keyboard-accessible by default. A button can be activated with Enter or Space. A link can be followed with Enter. Form inputs accept keyboard input naturally. When you use a <div> or <span> as an interactive element, you lose all built-in behavior and must manually add tabindex, role, onKeyDown, and onClick handlers. Always prefer native elements when a suitable one exists.
WCAG 2.4.7 requires that the keyboard focus indicator is visible at all times. The modern approach is to use :focus-visible which only shows the focus ring for keyboard navigation (not mouse clicks), giving you the best of both worlds. Never remove the browser default outline without providing a high-contrast replacement.
:focus-visible {
outline: 3px solid #3b82f6;
outline-offset: 2px;
border-radius: 4px;
}*:focus {
outline: none; /* NEVER do this without a replacement! */
}Skip navigation links are anchor links placed at the very beginning of a page. They are visually hidden until they receive keyboard focus, at which point they slide into view. When activated, they move focus directly to the main content area, allowing keyboard users to bypass the header, navigation menus, and other repetitive blocks that appear on every page. Without skip links, a keyboard user visiting your site must press Tab through every single navigation link on every page load before reaching the content.
WCAG Success Criterion 2.4.1 (Bypass Blocks) requires a mechanism for skipping repeated content. Skip links are the simplest, most universally supported approach. They are typically implemented as the very first element inside the <body> tag, styled off-screen by default, and animated into view on focus.
Press Tab to focus into this demo. The skip link will appear at the top-left. Press Enter to jump past the nav links directly to the main content area.
Focus landed here after activating the skip link, bypassing all four navigation links above.
<!-- Place as the FIRST element inside <body> -->
<a href="#main-content" class="skip-link">
Skip to main content
</a>
<header>
<nav>
<a href="/">Home</a>
<a href="/about">About</a>
<a href="/contact">Contact</a>
<a href="/blog">Blog</a>
</nav>
</header>
<main id="main-content" tabindex="-1">
<!-- Your page content -->
</main>.skip-link {
position: absolute;
top: -40px;
left: 0;
background: #1d4ed8;
color: white;
padding: 8px 16px;
z-index: 100;
font-size: 14px;
font-weight: 500;
border-radius: 0 0 4px 0;
transition: top 0.2s ease;
}
.skip-link:focus {
top: 0;
}In static HTML pages, focus management happens naturally -- the user tabs through elements in sequence. But modern web applications are dynamic: content loads asynchronously, pages change without full reloads, and elements appear and disappear in response to user actions. In these situations, you must programmatically move focus to keep keyboard users oriented.
Click "Load 3 More Items" and notice that focus automatically moves to the first new item. This is focus management in action.
After loading, focus moves to the first new item via a useRef + .focus() pattern.
import { useRef, useEffect, useState } from 'react';
function DynamicList() {
const [items, setItems] = useState(['Item 1', 'Item 2', 'Item 3']);
const [justAdded, setJustAdded] = useState(false);
const newItemRef = useRef<HTMLLIElement>(null);
// Move focus to the first new item after loading
useEffect(() => {
if (justAdded && newItemRef.current) {
newItemRef.current.focus();
setJustAdded(false);
}
}, [justAdded, items]);
const loadMore = () => {
const nextIndex = items.length + 1;
setItems(prev => [
...prev,
`Item ${nextIndex}`,
`Item ${nextIndex + 1}`,
`Item ${nextIndex + 2}`,
]);
setJustAdded(true);
};
return (
<div>
<ul>
{items.map((item, i) => (
<li
key={item}
ref={i === items.length - 3 && justAdded ? newItemRef : null}
tabIndex={i === items.length - 3 && justAdded ? -1 : undefined}
>
{item}
</li>
))}
</ul>
<button onClick={loadMore}>Load more items</button>
</div>
);
}Consider a toolbar with 10 buttons. If every button were in the tab order, a user would need to press Tab 10 times to move past the toolbar. The roving tabindex pattern solves this: only one element in the group has tabindex="0", making it the single tab stop. All other elements have tabindex="-1". Arrow keys move focus (and the tabindex="0" designation) between items within the group.
This pattern is used in toolbars, tab lists, menu bars, tree views, and radio groups. The user Tabs into the widget, uses Arrow keys to navigate within it, and Tabs out to the next component. Home jumps to the first item, End jumps to the last. This drastically reduces the number of keystrokes required to navigate past composite widgets.
Tab into the toolbar. Use Left/Right Arrow keys to move between buttons. Tab again to exit. Home/End jump to first/last button.
Active: Bold (tabindex="0"). All others have tabindex="-1".
import { useState, useRef, useEffect } from 'react';
function Toolbar() {
const [activeIndex, setActiveIndex] = useState(0);
const items = ['Bold', 'Italic', 'Underline', 'Link'];
const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]);
// Move DOM focus whenever activeIndex changes
useEffect(() => {
buttonRefs.current[activeIndex]?.focus();
}, [activeIndex]);
const handleKeyDown = (e: React.KeyboardEvent, index: number) => {
switch (e.key) {
case 'ArrowRight':
e.preventDefault();
setActiveIndex((index + 1) % items.length);
break;
case 'ArrowLeft':
e.preventDefault();
setActiveIndex((index - 1 + items.length) % items.length);
break;
case 'Home':
e.preventDefault();
setActiveIndex(0);
break;
case 'End':
e.preventDefault();
setActiveIndex(items.length - 1);
break;
}
};
return (
<div role="toolbar" aria-label="Formatting options">
{items.map((item, i) => (
<button
key={item}
ref={(el) => { buttonRefs.current[i] = el; }}
tabIndex={i === activeIndex ? 0 : -1}
onKeyDown={(e) => handleKeyDown(e, i)}
onClick={() => setActiveIndex(i)}
aria-pressed={i === activeIndex}
>
{item}
</button>
))}
</div>
);
}A keyboard trap occurs when a keyboard user navigates into a component and cannot navigate out using the keyboard alone. The Tab key and Shift+Tab either do nothing or cycle within the same component indefinitely. Common causes include custom widgets that intercept Tab key events without allowing escape, embedded third-party iframes (ads, chat widgets, video players), JavaScript event listeners that call e.preventDefault() on all keydown events, and auto-focus loops where a blur handler immediately refocuses the same element.
Switch between the "Bad" and "Good" tabs to compare. In the Bad tab, activating the trap prevents Tab from working. Press Escape to exit the trap.
Click "Activate Trap" to simulate a keyboard trap.
// BAD: This creates a keyboard trap!
element.addEventListener('keydown', (e) => {
e.preventDefault(); // Prevents ALL keys including Tab
// Handle your custom keys here
});
// BAD: Auto-focus loop
input.addEventListener('blur', () => {
input.focus(); // User can never leave this input
});// GOOD: Only prevent default for the keys you handle
element.addEventListener('keydown', (e) => {
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
e.preventDefault();
handleArrowNavigation(e.key);
}
// Tab, Shift+Tab, Escape pass through naturally
});
// GOOD: Validate on blur without trapping
input.addEventListener('blur', () => {
validateField(input); // Validate but do NOT refocus
});Modal dialogs are the single exception to the "no keyboard trap" rule. When a modal is open, focus should be trapped inside it because the background content is inert and not visible to the user. Without focus trapping, a keyboard user could Tab behind the modal into content they cannot see, which is even more disorienting than the trap itself.
aria-modal="true" and role="dialog".Click 'Open Dialog' to open the modal. Try Tab (cycles within), Shift+Tab (wraps backward), and Escape (closes and returns focus to the button).
Focus is trapped inside the dialog. Escape closes it. Focus returns to this button on close.
import { useRef, useEffect } from 'react';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
children: React.ReactNode;
}
function Modal({ isOpen, onClose, children }: ModalProps) {
const modalRef = useRef<HTMLDivElement>(null);
const previousFocusRef = useRef<Element | null>(null);
useEffect(() => {
if (isOpen) {
// 1. Store the currently focused element
previousFocusRef.current = document.activeElement;
// 2. Move focus into the modal
modalRef.current?.focus();
} else if (previousFocusRef.current) {
// 4. Return focus to the trigger on close
(previousFocusRef.current as HTMLElement)?.focus();
previousFocusRef.current = null;
}
}, [isOpen]);
const handleKeyDown = (e: React.KeyboardEvent) => {
// 3. Escape closes the modal
if (e.key === 'Escape') {
onClose();
return;
}
// 2. Trap Tab within the modal
if (e.key === 'Tab') {
const focusable = modalRef.current?.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (!focusable?.length) return;
const first = focusable[0] as HTMLElement;
const last = focusable[focusable.length - 1] as HTMLElement;
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus(); // Wrap backward
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus(); // Wrap forward
}
}
};
if (!isOpen) return null;
return (
<>
<div className="backdrop" onClick={onClose} aria-hidden="true" />
<div
role="dialog"
aria-modal="true"
aria-label="Modal title"
ref={modalRef}
tabIndex={-1}
onKeyDown={handleKeyDown}
>
{children}
</div>
</>
);
}When building custom interactive widgets -- dropdown menus, tab panels, tree views, sliders, comboboxes -- you must implement the keyboard patterns that users expect based on the WAI-ARIA Authoring Practices Guide. These conventions are what assistive technology users learn and rely on. Deviating from them means your widget will be unpredictable and unusable for keyboard-only users.
The table below summarizes the expected keyboard interactions for common widget types. When building any custom widget, always consult the WAI-ARIA Authoring Practices Guide for the full specification.
| Widget | Keys | Behavior |
|---|---|---|
| Button | Enter Space | Activate |
| Link | Enter | Navigate |
| Checkbox | Space | Toggle |
| Radio Group | Arrow keys | Select option |
| Tab Panel | Arrow keys | Switch tabs |
| Menu | Arrow keys Enter Escape | Navigate, select, close |
| Dialog | Tab Escape | Cycle focus, close |
| Combobox | Arrow keys Enter Escape | Navigate, select, close |
| Slider | Arrow keys | Adjust value |
Use this checklist every time you build or audit a page. Disconnect your mouse, put it in a drawer, and navigate the entire page using only your keyboard. Mark each item as you verify it. You can download a PDF copy of this checklist using the button below.
0 of 10 items completed
Comprehensive tools, checklists, and guides to help you create inclusive digital experiences