Using tabindex, focus, and standard patterns correctly
Websites that only work with a mouse exclude keyboard users, screen reader users, and people with motor impairments from key functionality. This article explains the WCAG baseline requirement of full keyboard operability: native tab order, correct use of tabindex, standard interaction patterns with Enter, Space, and arrow keys, and a practical keyboard only walkthrough of your own site as a first step.
Table of Contents
- 1. Why full keyboard operability is a WCAG baseline requirement
- 2. Native tab order and the role of DOM structure
- 3. tabindex: 0, -1, and why positive values are almost always wrong
- 4. Making focus visible: outline and :focus-visible
- 5. Standard interaction patterns: Enter and Space for buttons
- 6. Arrow keys for menus, tabs, and comboboxes: roving tabindex
- 7. Avoiding focus traps: modals and focus management
- 8. Skip links and landmarks for efficient navigation
- 9. The practical keyboard test: auditing your own site systematically
- 10. Summary
- 11. FAQ
1. Why full keyboard operability is a WCAG baseline requirement
Success Criterion 2.1.1 Keyboard of the WCAG requires that all functionality of a web application be operable through a keyboard interface, without requiring a specific timing for individual keystrokes. It is assigned to Level A, the most fundamental conformance level, whose violation does not just make access harder for certain user groups but denies it entirely. That sets keyboard operability apart from many other accessibility topics: there is no gradient between "somewhat better" and "optimal" here, only the binary question of whether a function is reachable and operable without a mouse or not.
Far more user groups are affected than you might assume at first glance: people with tremor, Parkinson's, or RSI conditions, people without hands or with limited fine motor control, switch device users, and temporarily impaired users, for example someone with a broken arm. Screen reader users also navigate primarily by keyboard, independent of their motor ability, because pointing visually with a mouse is not a meaningful interaction mode for them. In Germany, the Barrierefreiheitsstärkungsgesetz (BFSG) has made WCAG Level AA conformance, and therefore 2.1.1, a legal requirement for many B2C offerings since June 2025.
2. Native tab order and the role of DOM structure
The browser calculates tab order by default from the order of focusable elements in the DOM, not from their visual position on screen. As long as semantic HTML is written in a sensible reading order, the tab order automatically matches what users expect: top to bottom, left to right. This free correctness disappears the moment CSS decouples the visual arrangement from the DOM order, for example through order in flexbox or grid layouts that visually swap columns without changing the DOM order at all.
The result is a tab order that remains invisible to sighted mouse users but is immediately noticeable to keyboard users: focus appears to jump seemingly at random across the page, because it follows the invisible DOM order rather than the visible column arrangement. The most reliable rule is therefore: DOM order and visual order should always match. Where a visual reordering is unavoidable, for example in responsive layout changes between mobile and desktop, the tab order must be manually re-checked with the keyboard after every layout change, rather than relying on the visual result alone.
3. tabindex: 0, -1, and why positive values are almost always wrong
The tabindex attribute has three fundamentally different value ranges that are frequently confused. tabindex="0" adds a naturally non-focusable element like a div or span into the natural tab order at its position in the DOM, exactly like a native form field. tabindex="-1" makes an element programmatically focusable through JavaScript (element.focus()), but removes it from the tab sequence. That is the correct value for skip link targets, dialog headings after a modal opens, or inactive elements in a roving tabindex widget.
Positive values such as tabindex="1" or tabindex="5", by contrast, force a second, manually maintained order that takes precedence over the DOM order and conflicts with it. Every new element without an explicit number gets sorted after all positive values, regardless of its actual position. Every later code change requires this numbering to be adjusted by hand, which in practice almost never happens reliably. The consequence is an unpredictable tab order that degrades over time. That is why, in practice, the rule holds almost without exception: use only 0, -1, or no tabindex at all.
<!-- Natively focusable elements need no tabindex -->
<a href="/products">Products</a>
<button type="button">Cart</button>
<input type="text" name="search">
<!-- Bring a non-native element into the natural tab order -->
<div
role="button"
tabindex="0"
@click="toggleFilter()"
@keydown.enter="toggleFilter()"
@keydown.space.prevent="toggleFilter()">
Open filter
</div>
<!-- WRONG: positive tabindex values force a second, manual order -->
<input type="text" tabindex="3" name="zip">
<input type="text" tabindex="1" name="street">
<input type="text" tabindex="2" name="houseNumber">
<!-- Any later DOM change silently breaks this order -->
<!-- RIGHT: DOM order = tab order, no tabindex needed -->
<input type="text" name="street">
<input type="text" name="houseNumber">
<input type="text" name="zip">
4. Making focus visible: outline and :focus-visible
Success Criterion 2.4.7 Focus Visible requires that it always be recognizable which element currently holds keyboard focus. In practice, this criterion is violated most often by a single CSS line: outline: none; with no replacement at all, usually set because the native blue focus ring is perceived as visually disruptive. Without a visible focus indicator, a keyboard user loses all orientation about where they currently are on the page, which makes the interface practically unusable even if every element is technically focusable.
The modern solution is the :focus-visible pseudo class, which browsers activate heuristically only when focus was actually set via keyboard or another non-pointing device, not on a mouse click. That allows for a strong, clearly visible focus ring for keyboard users, without mouse users seeing a ring perceived as intrusive on every click. It is important to never remove the focus ring entirely, but always to replace it with a custom, high-contrast alternative that meets the WCAG contrast requirements for non-text elements.
/* WRONG: focus indicator removed entirely, violates WCAG 2.4.7 */
button:focus {
outline: none;
}
/* RIGHT: custom focus ring only for keyboard use, not mouse clicks */
.filter-button:focus {
outline: none;
}
.filter-button:focus-visible {
outline: 3px solid #18181b;
outline-offset: 2px;
border-radius: 4px;
}
/* Global baseline rule as a safety net for all interactive elements */
a:focus-visible,
button:focus-visible,
input:focus-visible,
[tabindex]:focus-visible {
outline: 2px solid #2563eb;
outline-offset: 2px;
}
5. Standard interaction patterns: Enter and Space for buttons
A native <button> element automatically triggers its click handler on both the Enter key and the Space key, with one subtle but important difference: Enter fires already on the keydown event, whereas Space only fires on the keyup event. This difference is not accidental, it prevents an accidental page scroll triggered by Space from simultaneously firing a click. Anyone rebuilding this semantics themselves, for example for a div role="button", must handle both keys separately and reproduce this exact timing, otherwise the behavior diverges from what keyboard users expect from every other web application.
That is exactly why the first rule of any keyboard implementation is: use a native interactive element before rebuilding your own behavior. A <button> brings this semantics, focusability, and the correct implicit ARIA role for free. Only when no native element fits, for example with complex custom widgets like a color picker, is a manual keydown/keyup implementation necessary, and it should then strictly follow the ARIA Authoring Practices rather than inventing a custom, divergent key behavior.
// A native <button> fires automatically on Enter (keydown) and Space (keyup)
// A custom role="button" must reproduce this behavior manually
const customButton = document.querySelector('[role="button"]');
customButton.addEventListener('keydown', (event) => {
// Enter fires immediately, just like a native button
if (event.key === 'Enter') {
event.preventDefault();
customButton.click();
}
// Space: prevent the default behavior (page scroll)
if (event.key === ' ') {
event.preventDefault();
}
});
customButton.addEventListener('keyup', (event) => {
// Space only fires on keyup, matching the native button
if (event.key === ' ') {
customButton.click();
}
});
6. Arrow keys for menus, tabs, and comboboxes: roving tabindex
Composite widgets such as menus, tab lists, radio groups, and comboboxes follow a different keyboard pattern than individual buttons: the Tab key moves focus into the entire widget only once, and out again on the next Tab press, while arrow keys move between individual options inside the widget. This pattern matches what users already know from native <select> elements and radio button groups, and is rebuilt through the roving tabindex pattern.
In the roving tabindex pattern, exactly one child element of the widget carries tabindex="0" at any given time, while all sibling elements carry tabindex="-1". An arrow key handler on the container moves both the tabindex attribute and the actual focus to the new active element on every key press. This way, the Tab key always sees only a single stop across the entire widget, while internal navigation is left to the arrow keys, exactly as the ARIA Authoring Practices prescribe for menus, tabs, and listboxes.
// Roving tabindex: only the active element has tabindex="0"
const menu = document.querySelector('[role="menu"]');
const items = [...menu.querySelectorAll('[role="menuitem"]')];
let activeIndex = 0;
function setActive(index) {
items[activeIndex].setAttribute('tabindex', '-1');
activeIndex = index;
items[activeIndex].setAttribute('tabindex', '0');
items[activeIndex].focus();
}
menu.addEventListener('keydown', (event) => {
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
setActive((activeIndex + 1) % items.length);
break;
case 'ArrowUp':
event.preventDefault();
setActive((activeIndex - 1 + items.length) % items.length);
break;
case 'Home':
event.preventDefault();
setActive(0);
break;
case 'End':
event.preventDefault();
setActive(items.length - 1);
break;
}
});
7. Avoiding focus traps: modals and focus management
A modal dialog must reliably keep keyboard focus within its own boundaries while it is open: Tab and Shift+Tab must not let focus escape into the hidden background content, and Escape must close the dialog and return focus to the triggering element, usually the button that opened the modal. Without this focus trap, a keyboard user ends up, after a few Tab presses, in the middle of a page whose visible context they no longer see, because the dialog visually sits on top while focus has long since wandered back into the background.
The native <dialog> element with the showModal() method handles a large part of this work automatically: it renders a top layer, prevents interaction with the rest of the document by default, and supports Escape to close. Self-built modals without this native element need an explicit JavaScript implementation that moves focus into the dialog on open, cycles Tab movement between the first and last focusable element inside the dialog, and correctly restores focus on close. Libraries like Alpine.js offer a ready-made, tested implementation of exactly this pattern with x-trap.
8. Skip links and landmarks for efficient navigation
Without a skip link, a keyboard user has to tab through the entire main navigation, search bar, and category bar on every page visit before reaching the actual page content, an effort that repeats on every single subpage. A skip link is the very first focusable element in the DOM, visually hidden until it receives focus, and jumps directly to the main content when activated. This simple pattern saves keyboard users on data-heavy pages like category or checkout pages dozens of unnecessary Tab presses per visit.
Landmark elements such as <header>, <nav>, <main>, and <footer> complement the skip link by offering screen readers an additional, keyboard-driven navigation layer between regions, independent of the linear tab pass. A <main> element with tabindex="-1" as the jump target ensures that focus after clicking the skip link is actually set programmatically on the main content, not just scrolled there visually, which makes the decisive difference for screen reader announcements.
<!-- Skip link: the first focusable element on the page -->
<a href="#main-content" class="skip-link">Skip to main content</a>
<header>
<nav aria-label="Main navigation"><!-- ... --></nav>
</header>
<!-- tabindex="-1": programmatically focusable after clicking the skip link -->
<main id="main-content" tabindex="-1">
<!-- ... -->
</main>
<footer><!-- ... --></footer>
<style>
.skip-link {
position: absolute;
left: -9999px;
top: 0;
z-index: 999;
}
.skip-link:focus {
left: 1rem;
top: 1rem;
background: #18181b;
color: #ffffff;
padding: 0.75rem 1.5rem;
border-radius: 0.5rem;
}
</style>
9. The practical keyboard test: auditing your own site systematically
The simplest and most telling first test for keyboard operability needs no software at all: physically unplug or ignore the mouse or trackpad and walk through your site's most important user flows exclusively with Tab, Shift+Tab, Enter, Space, the arrow keys, and Escape, from the homepage through product search to a complete checkout. Three questions should consistently answer Yes throughout: Is every interactive element reachable? Is focus clearly visible at all times? Does the order follow a logical, predictable structure?
Typical findings from this self test are dropdown menus that only respond to hover, cookie banners that do not automatically receive focus, carousels without arrow key control, and modals that cannot be dismissed with Escape. This manual test does not replace automated tools like axe-core, but it uncovers exactly the interactive behavior patterns that automated scanners cannot structurally detect, because they check raw DOM markup rather than actual keyboard behavior at runtime. The following overview summarizes the most common failure patterns and their correct implementation.
| Task | Unsafe / Wrong | Recommended pattern | Benefit |
|---|---|---|---|
| Clickable element | <div onclick> without a role |
<button> or role+tabindex+keydown |
Native keyboard semantics, no one excluded |
| Focus order | tabindex="1", tabindex="2" ... |
tabindex="0" + DOM order |
Predictable, maintainable order |
| Focus visibility | outline: none; with no replacement |
:focus-visible { outline: ...; } |
Meets WCAG 2.4.7, focus always recognizable |
| Dropdown menu | Only operable via hover | Arrow keys + roving tabindex | Expected, ARIA-compliant behavior |
| Modal dialog | Focus escapes into the background | Focus trap + Escape + focus return | No user gets stuck on the page |
This manual keyboard test can be run for a site's most important core flows in under fifteen minutes and reliably surfaces the most severe operability problems before any more elaborate audit tool is even used. Repeating this test regularly after every major frontend release prevents keyboard problems from quietly accumulating over time.
Mironsoft
Accessibility, WCAG audits, and accessibility implementation for Magento and Hyvä stores
Ready to close every keyboard gap?
We test your Magento or Hyvä store systematically with the keyboard, find focus traps, missing focus indicators, and misused tabindex, and implement the right fixes directly in your Alpine.js components.
Keyboard audit
Manual testing of all core flows, prioritized by user impact
Focus management
Retrofitting focus traps, skip links, and visible focus rings in Hyvä templates
Widget implementation
Roving tabindex for menus, tabs, and comboboxes following ARIA standards
10. Summary
Full keyboard operability is not an optional enhancement but a WCAG baseline requirement at Level A, whose absence denies keyboard users, screen reader users, and people with motor impairments access entirely. Native tab order follows DOM structure and should always match the visual arrangement. tabindex="0" adds elements into the natural order, tabindex="-1" makes them programmatically focusable without a tab stop, while positive values almost always create a fragile, error-prone second order and should be avoided.
Enter and Space must reproduce exactly the native behavior for buttons, arrow keys take over internal navigation in composite widgets through the roving tabindex pattern. Modals need a real focus trap with Escape support, and a skip link saves keyboard users countless unnecessary Tab presses on every page. The most effective first test remains simple: walk through your own site without a mouse, using only the keyboard, and watch closely whether every function stays reachable.
Keyboard Navigation: Fundamentals for Developers - The Essentials at a Glance
WCAG 2.1.1 Keyboard
Level A baseline requirement: every function must be reachable and operable without a mouse.
Using tabindex correctly
0 for natural order, -1 for programmatic focus, never positive values.
Follow standard patterns
Enter/Space for buttons, arrow keys for menus and tabs, Escape closes dialogs.
Self test as a first step
Unplug the mouse and walk through the complete core flows using only the keyboard.