Keyboard navigation is the foundation of web accessibility. Screen readers, switch devices, voice control, and many motor-disability tools all rely on keyboard input. If you can't reach an element by tabbing or activate it with Enter/Space, the site is broken for a large group of users. WCAG 2.1.1 (Keyboard) is a Level A requirement — the minimum legal bar. This guide covers the full keyboard fix: tab order, focus traps, custom widgets, skip links.
<a>, <button>, <input>, <select>, <textarea>, <details>, <summary> all tab-focusable automatically.
<!-- WRONG -->
<div onclick="openMenu()">Menu</div>
<!-- RIGHT -->
<button onclick="openMenu()">Menu</button>
<!-- IF YOU MUST use a div -->
<div role="button"
tabindex="0"
onclick="openMenu()"
onkeydown="if(event.key === 'Enter' || event.key === ' ') openMenu()">
Menu
</div>
The native button approach handles all this for free. Default to native elements.
<!-- WRONG — puts these at the START of tab order before everything else --> <input tabindex="1"> <input tabindex="2"> <input tabindex="3"> <!-- RIGHT — natural DOM order --> <input> <input> <input>
tabindex="0" — element joins natural tab order at its DOM positiontabindex="-1" — element is programmatically focusable but not in tab order (use for focus management of non-default elements)tabindex="1" and above — DO NOT USE — breaks user expectationA focus trap is when Tab moves focus to an element and you can't get out. Two kinds:
A widget grabs focus and doesn't release it. Often: video players that capture all key events, infinite-loop modal dialogs.
Modals SHOULD trap focus while open — Tab cycles within the modal, Escape closes. The fix is correct trap behaviour, not removal.
// Correct modal focus management
function openModal(modal) {
const previouslyFocused = document.activeElement;
// Move focus into modal
modal.querySelector('h2').focus();
// Trap Tab within modal
modal.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeModal(modal, previouslyFocused);
if (e.key === 'Tab') {
const focusables = modal.querySelectorAll('a, button, input, [tabindex="0"]');
const first = focusables[0];
const last = focusables[focusables.length - 1];
if (e.shiftKey && document.activeElement === first) {
last.focus(); e.preventDefault();
} else if (!e.shiftKey && document.activeElement === last) {
first.focus(); e.preventDefault();
}
}
});
}
function closeModal(modal, previouslyFocused) {
modal.setAttribute('hidden', '');
previouslyFocused.focus(); // restore focus to opener
}
<dialog> element with showModal() handles focus trap automatically. Use it when browser support allows. Saves implementing the trap logic yourself.Keyboard users hitting Tab on every page first traverse the main navigation before reaching content. A skip link lets them jump directly to <main>.
<body>
<a href="#main" class="skip-link">Skip to main content</a>
<header>...nav...</header>
<main id="main" tabindex="-1">...content...</main>
</body>
<style>
.skip-link {
position: absolute;
top: -100px;
left: 8px;
background: #1e3a8a;
color: white;
padding: 12px 16px;
z-index: 1000;
text-decoration: none;
}
.skip-link:focus {
top: 8px;
}
</style>
The skip link is visually hidden until focused via Tab. First-time keyboard users see it appear; mouse users never see it.
Each widget pattern has standard keyboard expectations. Following ARIA Authoring Practices means users coming from desktop applications find familiar behaviour.
test('can navigate menu with keyboard', async ({ page }) => {
await page.goto('/');
await page.keyboard.press('Tab'); // skip link
await page.keyboard.press('Tab'); // logo
await page.keyboard.press('Tab'); // first nav link
await page.keyboard.press('Enter');
await expect(page).toHaveURL(/about/);
});
Verify keyboard-navigation findings are cleared.
Run Accessibility Audit →