View contents
Introduction
Keyboard navigation is a Level A requirement in WCAG 2.2, which makes it the accessibility floor rather than the ceiling. Criteria 2.1.1 (Keyboard) and 2.1.2 (No Keyboard Trap) state that every piece of functionality on a page must be operable by keyboard, and that a user must never get stuck inside a component with no way out.
This affects more people than is usually assumed. Screen reader users navigate by keyboard exclusively. People with motor disabilities using alternative input devices — switches, head pointers — generate keyboard events, not mouse events. Power users prefer keyboard shortcuts for speed. WebAIM puts keyboard accessibility problems among the five most common errors found in website audits.
This guide covers the core implementation patterns: tabindex management, keyboard event handling, the roving tabindex pattern for composite widgets, focus traps for modals, and skip links.
Focus management fundamentals
The tabindex attribute
The tabindex attribute controls how elements take part in Tab navigation. It has three functional values, and their behaviour differs sharply:
tabindex="0": The element joins the natural tab order, determined by its position in the DOM. This is the right value for making a custom element focusable, such as a<div>withrole="button".tabindex="-1": The element can receive focus programmatically, viaelement.focus(), but does not appear in the Tab flow. Useful for dialog containers and skip link destinations.tabindex="1+"(positive values): The element jumps to the front of the tab order. It looks useful and causes serious problems: the Tab flow stops following the page's visual order, which disorients users. The W3C explicitly recommends against positive values.
Interactive HTML elements (<a href>, <button>, <input>, <select>, <textarea>) are focusable natively, with no tabindex needed. That is the main argument for using native elements instead of building custom controls out of <div>: the keyboard behaviour is already there.
Focus order and visual order
Tab order follows DOM order, not the visual layout on screen. If you use CSS flex-direction: column-reverse or order to rearrange elements visually, Tab will still walk them in the order they appear in the HTML. That mismatch between what the user sees and what the keyboard does is a frequent source of confusion.
The preferred fix is to make DOM order match visual order. Where that is not possible, document the behaviour and confirm the tab sequence still reads as logical even though it differs from the visual layout.
Keyboard event handlers
When you build custom components that do not use native HTML elements, you have to implement by hand the keyboard events the browser would have provided. The basic pattern captures keydown and runs the matching action for the key pressed.
function handleKeyboard(element, actions) {
element.addEventListener('keydown', (e) => {
const handlers = {
'Enter': actions.activate,
' ': actions.activate,
'ArrowUp': actions.previous,
'ArrowDown': actions.next,
'Home': actions.first,
'End': actions.last,
'Escape': actions.cancel
};
const handler = handlers[e.key];
if (handler) {
e.preventDefault();
handler(e);
}
});
}
Two details matter here. First, e.preventDefault() stops the browser running the key's default behaviour — the space bar scrolling the page, for instance. Second, it reads e.key rather than e.keyCode, since keyCode is deprecated and key gives readable values such as 'Enter' or 'ArrowDown'.
For a <div> acting as a button, the minimum implementation needs both Enter and Space to trigger the action, and the element to carry role="button" and tabindex="0":
<div role="button" tabindex="0" onclick="handleClick()">Action</div>
element.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
element.click();
}
});
The standing recommendation, though, is to reach for a native <button>, which brings all of this behaviour with no extra code.
Roving tabindex for composite widgets
Composite widgets such as toolbars, tab lists and menus need particular behaviour: only one element in the group is reachable by Tab, and the arrow keys move focus between the elements inside. The pattern is called "roving tabindex" because tabindex="0" moves between elements as the user navigates.
The idea is that the whole widget occupies a single Tab stop in the page flow. A toolbar with ten buttons should not take ten Tab presses to cross. Instead, Tab reaches the widget, the arrows navigate inside it, and the next Tab leaves for the next component on the page.
The implementation gives tabindex="0" to the active element and tabindex="-1" to the rest. When the user presses an arrow, the tabindex swaps and focus moves to the new element:
// On init: only the first element gets tabindex="0"
items.forEach((item, i) => {
item.setAttribute('tabindex', i === 0 ? '0' : '-1');
});
// On arrow navigation:
function moveFocus(fromIndex, toIndex) {
items[fromIndex].setAttribute('tabindex', '-1');
items[toIndex].setAttribute('tabindex', '0');
items[toIndex].focus();
}
Home and End should jump to the first and last element respectively. The arrows should wrap: from the last element, ArrowDown returns to the first.
Focus trap for modal dialogs
Criterion 2.1.2 forbids keyboard traps, but modal dialogs are the legitimate exception: while a modal is open, focus must stay inside it until the user closes it. That keeps the user from interacting by accident with content behind the modal that they cannot see.
The implementation has three phases. On opening, store the element that had focus, find every focusable element inside the modal, and move focus to the first of them. While the modal is open, when Tab reaches the last focusable element the next Tab returns to the first (and Shift+Tab from the first goes to the last). On closing, focus returns to the element that held it before.
function activateFocusTrap(modal) {
const focusables = modal.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const first = focusables[0];
const last = focusables[focusables.length - 1];
modal.addEventListener('keydown', (e) => {
if (e.key !== 'Tab') return;
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
});
first.focus();
}
Escape must always close the modal. Every keyboard user expects it, and it is documented in the WAI-ARIA Authoring Practices as a requirement of the dialog pattern.
Skip links: bypassing repeated blocks
Skip links are hidden links that appear when focused and let keyboard users jump straight to the main content without walking the entire navigation. They satisfy WCAG 2.4.1 (Bypass Blocks).
The implementation combines HTML, CSS and a little JavaScript. The link points at the id of the <main> element, stays off screen until focused, and the destination needs tabindex="-1" so it can take programmatic focus:
<a href="#main-content" class="skip-link">Skip to main content</a>
<!-- ... navigation ... -->
<main id="main-content" tabindex="-1">
.skip-link {
position: absolute;
top: -100%;
left: 50%;
transform: translateX(-50%);
padding: 1rem 2rem;
background: #000;
color: #fff;
z-index: 9999;
}
.skip-link:focus { top: 0; }
If the site has several important regions — main content, search, secondary navigation — you can offer more than one skip link. Each needs text that describes its destination clearly.
Key reference by component
Each kind of widget has keyboard conventions documented in the WAI-ARIA Authoring Practices that assistive technology users expect to find:
| Component | Required keys | Behaviour |
|---|---|---|
| Button | Enter, Space | Triggers the button's action |
| Link | Enter | Navigates to the link destination |
| Dropdown menu | Arrows, Enter, Escape | Arrows navigate, Enter selects, Escape closes |
| Tabs | Left/right arrows, Enter | Arrows change the active tab |
| Modal dialog | Tab (internal cycle), Escape | Tab cycles inside the modal, Escape closes |
| Listbox | Arrows, Home, End | Arrows move through options, Home/End jump to the ends |
| Accordion | Enter, Space | Expands or collapses the section |
Checklist
- [ ] Every interactive element is reachable by keyboard
- [ ] Tab order follows the visual reading order
- [ ] No positive tabindex values are used
- [ ] Composite widgets implement roving tabindex
- [ ] Enter and Space activate buttons and controls
- [ ] Arrow keys navigate inside composite widgets
- [ ] Escape closes modals, menus and popups
- [ ] No keyboard traps exist (open modals aside)
- [ ] Skip links let users bypass repeated navigation
- [ ] Focus is restored when modals and menus close
- [ ] Focus indicators are visible and have enough contrast
Frequently asked questions
Why should I avoid positive tabindex values?
Positive tabindex values (1, 2, 3…) place the element ahead of every element with tabindex="0" in the tab order, regardless of DOM position. That produces a Tab flow that does not match the page's visual order, which disorients users. The W3C explicitly recommends avoiding them and organising the DOM so the natural order is the right one.
What is the difference between roving tabindex and aria-activedescendant?
Both patterns solve the same problem — navigation inside composite widgets — in different ways. Roving tabindex moves real DOM focus between elements, changing tabindex values as it goes. aria-activedescendant keeps focus on the container and uses an ARIA attribute to say which child is "active". Roving tabindex has better screen reader support and is the approach the WAI-ARIA Authoring Practices recommend for most components.
Do I have to implement every keyboard shortcut in the WAI-ARIA Authoring Practices?
The keys documented there split into required and optional. The required ones — Enter, Space, arrows, Escape, depending on the component — must always be implemented. The optional ones, such as type-ahead to select by character in a list, improve the experience but are not needed for WCAG conformance.
How do I check that no keyboard traps exist?
The most direct test is to navigate the whole page using only Tab and Shift+Tab. If at any point you can move neither forward nor back, there is a trap. Pay particular attention to modals, iframes, video players, rich text editors and third-party components, which are the most common sources.
Related Articles
- Keyboard Navigation Explained - Fundamentals and WCAG requirements
- Focus Indicators Guide - Accessible focus styles
- WCAG Compliance Hub - All accessibility checks