- by Wouter Nordsiek
- 0 Comments
- Guide
- 27 February 2026
Keyboard Navigation: A Developer’s Guide to WCAG Compliance
Guide #2 in the NGI Accessibility Series | Skill level: Intermediate | Time: 20-30 minutes
What this guide covers: Keyboard navigation is how many users interact with the web — people using screen readers, those with motor impairments, power users, and anyone with a broken mouse. Our audit of 78 NGI-funded projects uncovered 2,410 keyboard navigation violations across 77 of those 78 projects. That is a near-universal failure rate.
This guide walks through the most common keyboard problems we found and gives you production-ready code to fix each one. Every fix targets WCAG 2.1.1 Keyboard (Level A), plus the related criteria 2.4.3 Focus Order and 2.1.2 No Keyboard Trap.
Quick Reference: Keyboard Interactions
Before fixing anything, you need to know which keys do what. This table covers the expected keyboard behaviour for common UI patterns. If your components do not respond to these keys, they have a keyboard accessibility problem.
| Key | Expected Behaviour | Context |
|---|---|---|
| Tab | Move focus to the next interactive element | Global navigation |
| Shift + Tab | Move focus to the previous interactive element | Global navigation |
| Enter | Activate a link or button | Links, buttons, form submissions |
| Space | Activate a button, toggle a checkbox | Buttons, checkboxes, radio buttons |
| Arrow Keys | Navigate within a composite widget | Tabs, menus, radio groups, sliders |
| Escape | Close or dismiss the current overlay | Modals, dropdowns, tooltips |
| Home / End | Jump to first/last item in a list | Menus, tabs, listboxes |
Common Problems Found in Our Audits
These are the patterns we saw most often across the 77 failing projects. Each section shows the broken version, explains why it fails, and provides the fix.
1. Elements Not Reachable via Tab
The single biggest offender. Developers build clickable elements from <div> or <span> tags, and these are invisible to the keyboard. A mouse user clicks them fine. A keyboard user cannot reach them at all.
✗ Inaccessible — div with click handler
<div class="card" onclick="openProject()">
View Project Details
</div>
This <div> never receives focus. Keyboard users cannot interact with it.
✓ Accessible — use a real button
<button class="card" onclick="openProject()">
View Project Details
</button>
A <button> is focusable and responds to Enter and Space out of the box.
✓ Alternative — if you must use a div
<div class="card"
role="button"
tabindex="0"
onclick="openProject()"
onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();openProject()}">
View Project Details
</div>
This adds tabindex="0", role="button", and a keydown handler. But it is more code and more risk. Use a real <button> when you can.
2. Custom Components Missing Keyboard Support
Custom sliders, toggles, accordions, and star ratings built from scratch almost always lack keyboard support. The WAI-ARIA Authoring Practices define the expected keyboard patterns for every widget type.
✗ Custom toggle with no keyboard support
<div class="toggle" onclick="toggleSetting(this)">
<div class="toggle-knob"></div>
</div>
✓ Accessible toggle using role=”switch” with aria-checked
<button class="toggle"
role="switch"
aria-checked="false"
aria-label="Enable dark mode"
onclick="toggleSetting(this)">
<span class="toggle-knob"></span>
</button>
<script>
function toggleSetting(btn) {
const isOn = btn.getAttribute('aria-checked') === 'true';
btn.setAttribute('aria-checked', String(!isOn));
}
</script>
Using <button> means keyboard access is built in. The role="switch" and aria-checked attributes tell screen readers the current state.
3. Dropdown Menus Not Keyboard Accessible
Dropdown menus that open on hover but close when you try to tab into them. Or menus that open but do not support arrow key navigation. Both are WCAG failures. See Guide #1: Accessible Dropdown Menus for a deep treatment of this topic.
:hover dropdown is never keyboard accessible. You always need JavaScript to handle Enter/Space to open, Arrow keys to navigate, and Escape to close.
4. Modals Without Focus Trapping
When a modal opens and the user presses Tab, focus should stay within the modal. If it escapes behind the overlay, the user is stuck navigating invisible content. This violates WCAG 2.1.2 No Keyboard Trap — or rather, it is the opposite problem: focus is not trapped enough inside the modal, so the user gets lost.
✗ Modal opens but focus is not managed
<div class="modal" id="myModal">
<div class="modal-content">
<h2>Subscribe</h2>
<input type="email" placeholder="Email">
<button onclick="closeModal()">Close</button>
</div>
</div>
<script>
function openModal() {
document.getElementById('myModal').style.display = 'block';
// No focus management at all
}
</script>
✓ Modal with proper focus trap (see full implementation below)
<div class="modal" id="myModal" role="dialog"
aria-labelledby="modal-title" aria-modal="true">
<div class="modal-content">
<h2 id="modal-title">Subscribe</h2>
<input type="email" placeholder="Email">
<button onclick="closeModal()">Close</button>
</div>
</div>
5. Missing Skip Links
Without a skip link, keyboard users must tab through the entire site header and navigation on every single page load before reaching the main content. On a site with 30 nav links, that is 30 tab presses wasted.
✗ No skip link — keyboard users tab through entire nav
<body>
<header>
<nav>
<a href="/">Home</a>
<a href="/about">About</a>
<!-- 20 more links... -->
</nav>
</header>
<main>...</main>
</body>
✓ Skip link as first focusable element
<body>
<a href="#main-content" class="skip-link">
Skip to main content
</a>
<header>
<nav>...</nav>
</header>
<main id="main-content" tabindex="-1">
...
</main>
</body>
Implementation Guide
A. Making Custom Buttons Keyboard Accessible
Whenever you absolutely cannot use a native <button>, here is the minimum you need to add to a <div> or <span>.
Custom Button with Full Keyboard Support
<!-- The element -->
<span class="custom-btn"
role="button"
tabindex="0"
aria-label="Save document"
id="save-btn">
Save
</span>
<script>
const btn = document.getElementById('save-btn');
btn.addEventListener('click', handleSave);
btn.addEventListener('keydown', function(e) {
// Buttons must respond to Enter and Space
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault(); // Prevent page scroll on Space
handleSave();
}
});
function handleSave() {
console.log('Document saved');
}
</script>
<button> whenever possible. It handles Enter, Space, focus, and form submission natively. The code above is a fallback for cases where you are stuck with a non-semantic element.
B. Tab Order Management
The tabindex attribute controls whether and how an element participates in keyboard navigation. There are three values you should know:
| Value | Meaning | When to Use |
|---|---|---|
tabindex="0" |
Element is focusable in the normal tab order | Custom interactive elements that need keyboard access |
tabindex="-1" |
Element is focusable via JavaScript but not via Tab | Elements you focus programmatically (modal targets, skip link targets) |
tabindex="1+" |
Element jumps ahead in tab order | Never. This breaks the natural reading order and causes confusion. |
tabindex="5" or tabindex="100" forces that element to receive focus before everything else, regardless of its position in the DOM. This creates an unpredictable tab order that frustrates users. We found this pattern in multiple audited projects.
✗ Positive tabindex values — broken tab order
<input tabindex="3" placeholder="Last name">
<input tabindex="1" placeholder="First name">
<input tabindex="2" placeholder="Email">
Focus order: First name, Email, Last name. Confusing and inaccessible.
✓ Natural DOM order — no tabindex needed for native elements
<input placeholder="First name">
<input placeholder="Last name">
<input placeholder="Email">
Focus follows the source order. Predictable and correct.
C. Skip Links Implementation
A skip link is visually hidden until focused, then appears prominently. It must be the first focusable element on the page.
Production-Ready Skip Link
<!-- Place as the FIRST element inside <body> -->
<a href="#main-content" class="skip-link">
Skip to main content
</a>
<!-- Your header and nav go here -->
<main id="main-content" tabindex="-1">
<!-- Page content -->
</main>
<style>
.skip-link {
position: absolute;
top: -100%;
left: 16px;
z-index: 10000;
padding: 12px 24px;
background: #d63384;
color: #fff;
font-weight: 600;
font-size: 16px;
text-decoration: none;
border-radius: 0 0 4px 4px;
transition: top 0.2s ease;
}
.skip-link:focus {
top: 0;
outline: 3px solid #ffc107;
outline-offset: 2px;
}
</style>
<script>
// Fix for some browsers that don't move focus to the target
document.querySelector('.skip-link').addEventListener('click', function(e) {
const target = document.querySelector(this.getAttribute('href'));
if (target) {
target.focus();
}
});
</script>
Why tabindex="-1" on <main>? The <main> element is not natively focusable. Adding tabindex="-1" allows the skip link to move focus there programmatically, without adding it to the normal tab sequence.
D. Focus Trapping for Modals
A focus trap keeps Tab cycling within the modal and lets Escape close it. When the modal closes, focus must return to the element that opened it.
Reusable Focus Trap Module
<!-- Modal markup -->
<div id="modal-overlay" class="modal-overlay" hidden>
<div role="dialog" aria-labelledby="dlg-title" aria-modal="true"
class="modal-dialog">
<h2 id="dlg-title">Confirm Action</h2>
<p>Are you sure you want to proceed?</p>
<button id="modal-confirm">Confirm</button>
<button id="modal-cancel">Cancel</button>
</div>
</div>
<button id="open-modal-btn">Open Modal</button>
<script>
function trapFocus(element) {
const focusableSelectors = [
'a[href]', 'button:not([disabled])', 'input:not([disabled])',
'select:not([disabled])', 'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])'
];
const focusableEls = element.querySelectorAll(focusableSelectors.join(','));
const firstEl = focusableEls[0];
const lastEl = focusableEls[focusableEls.length - 1];
function handleKeydown(e) {
if (e.key === 'Tab') {
if (e.shiftKey) {
// Shift+Tab: if on first element, wrap to last
if (document.activeElement === firstEl) {
e.preventDefault();
lastEl.focus();
}
} else {
// Tab: if on last element, wrap to first
if (document.activeElement === lastEl) {
e.preventDefault();
firstEl.focus();
}
}
}
if (e.key === 'Escape') {
closeModal();
}
}
element.addEventListener('keydown', handleKeydown);
// Focus the first focusable element
firstEl.focus();
// Return cleanup function
return function() {
element.removeEventListener('keydown', handleKeydown);
};
}
let cleanupTrap = null;
let previouslyFocused = null;
function openModal() {
const overlay = document.getElementById('modal-overlay');
previouslyFocused = document.activeElement;
overlay.hidden = false;
cleanupTrap = trapFocus(overlay.querySelector('[role="dialog"]'));
}
function closeModal() {
const overlay = document.getElementById('modal-overlay');
overlay.hidden = true;
if (cleanupTrap) cleanupTrap();
if (previouslyFocused) previouslyFocused.focus(); // Return focus
}
document.getElementById('open-modal-btn').addEventListener('click', openModal);
document.getElementById('modal-cancel').addEventListener('click', closeModal);
document.getElementById('modal-confirm').addEventListener('click', function() {
// Perform action, then close
closeModal();
});
</script>
- Store the element that opened the modal (
previouslyFocused) and return focus to it on close. - Use
aria-modal="true"so screen readers know content behind the modal is inert. - The cleanup function removes the event listener to avoid memory leaks.
E. Arrow Key Navigation for Menus and Tabs
For composite widgets (components made up of multiple related interactive elements, like tab lists, menu bars, and radio groups), the roving tabindex pattern is the standard approach. The idea: only one item in the group has tabindex="0" (the active one); all others have tabindex="-1". As the user presses arrow keys, the tabindex="0" value moves — or “roves” — to the newly focused item.
Roving Tabindex for a Tab Widget
<div class="tabs">
<div role="tablist" aria-label="Project info">
<button role="tab" id="tab-1" aria-selected="true"
aria-controls="panel-1" tabindex="0">
Overview
</button>
<button role="tab" id="tab-2" aria-selected="false"
aria-controls="panel-2" tabindex="-1">
Details
</button>
<button role="tab" id="tab-3" aria-selected="false"
aria-controls="panel-3" tabindex="-1">
Contact
</button>
</div>
<div role="tabpanel" id="panel-1" aria-labelledby="tab-1">
<p>Overview content here.</p>
</div>
<div role="tabpanel" id="panel-2" aria-labelledby="tab-2" hidden>
<p>Details content here.</p>
</div>
<div role="tabpanel" id="panel-3" aria-labelledby="tab-3" hidden>
<p>Contact content here.</p>
</div>
</div>
<script>
function initTabs(tablistElement) {
const tabs = Array.from(tablistElement.querySelectorAll('[role="tab"]'));
tablistElement.addEventListener('keydown', function(e) {
const currentIndex = tabs.indexOf(document.activeElement);
if (currentIndex === -1) return;
let newIndex;
switch (e.key) {
case 'ArrowRight':
newIndex = (currentIndex + 1) % tabs.length;
break;
case 'ArrowLeft':
newIndex = (currentIndex - 1 + tabs.length) % tabs.length;
break;
case 'Home':
newIndex = 0;
break;
case 'End':
newIndex = tabs.length - 1;
break;
default:
return; // Let other keys pass through
}
e.preventDefault();
activateTab(tabs, newIndex);
});
tabs.forEach(function(tab, i) {
tab.addEventListener('click', function() {
activateTab(tabs, i);
});
});
}
function activateTab(tabs, index) {
tabs.forEach(function(tab, i) {
const isSelected = i === index;
tab.setAttribute('aria-selected', String(isSelected));
tab.setAttribute('tabindex', isSelected ? '0' : '-1');
const panelId = tab.getAttribute('aria-controls');
const panel = document.getElementById(panelId);
if (panel) {
panel.hidden = !isSelected;
}
});
tabs[index].focus();
}
// Initialize
const tablist = document.querySelector('[role="tablist"]');
if (tablist) initTabs(tablist);
</script>
Interactive Demo: Keyboard-Accessible Tab Widget
This is a working tab widget. Try using it with your keyboard only — no mouse. Tab to reach the tab list, then use Arrow Left / Arrow Right to switch tabs. Home and End jump to the first and last tab.
Keyboard Basics
Every interactive element on your page should be operable with a keyboard. This means links, buttons, form controls, and custom widgets all need to respond to standard key presses. Native HTML elements like <button> and <a> handle this automatically. Custom elements built from <div> or <span> require explicit keyboard event handlers.
Test this: press Tab to leave the tab list, then Shift+Tab to come back. Use Arrow Right to move to the next tab.
Focus Management
Focus management means controlling which element has focus and when. When a modal opens, focus should move into the modal. When it closes, focus should return to the trigger. When a section of the page updates dynamically, consider whether focus needs to move to the new content.
The :focus-visible CSS pseudo-class lets you show focus indicators only for keyboard users, not mouse clicks. This is now supported in all modern browsers.
ARIA Roles
ARIA roles like role="tab", role="tabpanel", and role="tablist" tell assistive technology what type of widget is on the page. But ARIA only changes how the element is announced — it does not add behaviour. You still need to implement keyboard navigation yourself.
The first rule of ARIA: do not use ARIA if you can use a native HTML element instead.
Testing Checklist
Unplug your mouse (or put it in a drawer) and work through this list. If you get stuck at any point during keyboard testing, you have found a violation.
Manual Keyboard Testing Steps
- Press Tab repeatedly from the top of the page. Can you reach every interactive element (links, buttons, inputs, controls)?
- Is there a visible focus indicator on every focused element? Can you always tell where you are?
- Does the focus order match the visual layout? No unexpected jumps?
- Can you activate all buttons with Enter and Space?
- Can you follow all links with Enter?
- Can you open and close dropdown menus with Enter/Space and Escape?
- Do menus support Arrow key navigation between items?
- Is there a skip link that appears on the first Tab press? Does it actually move focus past the navigation?
- When a modal opens, does focus move into the modal?
- Can you Tab through all modal controls without focus escaping behind the overlay?
- Does Escape close the modal and return focus to the trigger element?
- Do tab widgets support Arrow Left/Arrow Right navigation?
- Are there any keyboard traps where you get stuck and cannot Tab away?
- Can you operate custom widgets (sliders, date pickers, carousels) using only the keyboard?
- Do form error messages get announced when they appear? Can you navigate to them?
Browser Support
The keyboard patterns in this guide work across all modern browsers. The main difference is in focus styling defaults.
| Feature | Chrome | Firefox | Safari | Edge |
|---|---|---|---|---|
tabindex |
Full | Full | Full | Full |
:focus-visible |
86+ | 85+ | 15.4+ | 86+ |
inert attribute |
102+ | 112+ | 15.5+ | 102+ |
aria-modal |
Full | Full | Full | Full |
Keyboard events (keydown) |
Full | Full | Full | Full |
inert: The inert attribute makes an element and all its children non-interactive and invisible to assistive technology. For modals, adding inert to the content behind the dialog is a more reliable alternative to aria-modal="true", which has inconsistent screen reader support (particularly in Safari + VoiceOver).
WCAG References
| Criterion | Level | Summary |
|---|---|---|
| 2.1.1 Keyboard | A | All functionality must be operable through a keyboard interface. |
| 2.1.2 No Keyboard Trap | A | If focus can be moved to a component via keyboard, it must also be movable away. |
| 2.4.3 Focus Order | A | Focusable components receive focus in an order that preserves meaning and operability. |
| 2.4.7 Focus Visible | AA | Any keyboard operable UI has a visible focus indicator. |
| 2.4.11 Focus Not Obscured | AA | Focused element is not entirely hidden by other content (WCAG 2.2). |
Further Reading
- WAI-ARIA Authoring Practices Guide — Keyboard interaction patterns for every widget type
- ARIA Tabs Pattern — The definitive spec for tab widget keyboard behaviour
- ARIA Dialog (Modal) Pattern — Focus management and trapping requirements
- WebAIM: Keyboard Accessibility — Practical testing guide
