Keyboard Navigation and Tab Order
Why Keyboard Navigation Matters More Than You Think
Here's a stat that might surprise you: keyboard navigation isn't just for screen reader users. Power users, developers, people with motor impairments, people with a broken trackpad, people using TV remotes — all of them rely on the keyboard. Some studies estimate that up to 25% of users use keyboard navigation at least some of the time.
And here's the uncomfortable truth: if your site only works with a mouse, you've broken it for a significant chunk of your users. Not "degraded the experience" — broken it. A button that can't be reached with Tab is as useless as a button hidden behind a white div.
The good news? The browser gives you keyboard navigation for free — if you use the right HTML elements. The bad news? It's shockingly easy to break it, and most developers do without realizing it.
Think of tab order like a single-lane road through your page. The browser lays out this road based on the DOM order of focusable elements. Each press of Tab moves one stop forward along the road. Shift+Tab goes backward. Every interactive element — links, buttons, inputs — is a stop on this road. Non-interactive elements like div and span are not stops at all, unless you explicitly add them. The moment you start manually rearranging stops (positive tabindex), you create a confusing detour that breaks the natural flow.
Tab Order Follows DOM Order
This is the foundational rule that everything else builds on: the order elements receive focus when you press Tab matches the order those elements appear in the DOM, not their visual position on screen.
Naturally focusable elements (the ones that get a stop on the tab road without any extra work):
awith anhrefattributebutton(not disabled)input,select,textarea(not disabled)details/summary- Elements with
contenteditable
<!-- Tab order: Name → Email → Subscribe → Learn More -->
<input type="text" placeholder="Name" />
<input type="email" placeholder="Email" />
<button>Subscribe</button>
<a href="/about">Learn More</a>
This order is intuitive because the DOM order matches the visual order. But watch what happens when CSS reorders the visual layout:
<style>
.container { display: flex; flex-direction: row-reverse; }
</style>
<div class="container">
<button>First in DOM, last visually</button>
<button>Second in DOM, first visually</button>
</div>
Visually, users see "Second" on the left and "First" on the right. But Tab goes to "First" first — because it's first in the DOM. The user sees focus jump to the right side, then back to the left. Confusing.
CSS order, flex-direction: row-reverse, grid placement, and position: absolute all create visual reordering without changing DOM order. Tab order follows the DOM, not the visual layout. If your visual and DOM orders don't match, keyboard users will experience focus jumping around the page randomly. Fix the DOM order to match visual order, or use CSS that does not reorder (preferred).
The tabindex Attribute
tabindex gives you control over which elements are focusable and how they participate in the tab order. But it's a scalpel, not a hammer — most of the time, you don't need it at all.
There are exactly three values you should know:
tabindex 0 — Add to the Tab Order
Makes a non-interactive element focusable and places it in the natural tab order (based on DOM position). Use this when you're building a custom interactive widget from a div or span.
<!-- This div is now focusable via Tab, in DOM order -->
<div tabindex="0" role="button" aria-label="Close dialog">
X
</div>
But here's the thing — if you're adding tabindex="0", role="button", and an aria-label to a div, you should probably just use a button element. The button gives you all of that for free, plus keyboard activation (Enter and Space), plus form submission, plus correct accessibility semantics.
tabindex -1 — Focusable, But Not in Tab Order
Makes an element focusable via JavaScript (element.focus()), but removes it from the Tab key sequence. Users can't Tab to it, but your code can send focus there programmatically.
This is essential for:
- Focus management — moving focus to a modal when it opens, to an error message after form validation, or to a section after a skip link
- Roving tabindex — only one item in a widget group is tabbable (covered later)
- Non-interactive but scrollable containers — a code block that needs to be scrollable via keyboard
// After opening a modal, move focus to its heading
const modal = document.getElementById('modal-title');
modal.focus(); // Works because it has tabindex="-1"
<h2 id="modal-title" tabindex="-1">Confirm deletion</h2>
Positive tabindex Values — Never Do This
Values like tabindex="1", tabindex="5", or tabindex="100" place the element before everything with tabindex="0" or no tabindex. Elements with positive tabindex are visited first (in ascending order), then all the natural tab-order elements follow.
<!-- DON'T: Tab order becomes Help → Settings → Name → Email → Submit -->
<input type="text" placeholder="Name" />
<input type="email" placeholder="Email" />
<button>Submit</button>
<button tabindex="1">Help</button>
<button tabindex="2">Settings</button>
This is almost always wrong. It creates a tab order that diverges from the visual and DOM order, confusing every keyboard user. It's also unmaintainable — adding a new element means recalculating all the positive values.
WCAG 2.1 Success Criterion 2.4.3 (Focus Order) requires that focus order preserves meaning and operability. Positive tabindex values almost always violate this by creating a tab order that does not match the visual or logical order of the page.
focus-visible vs focus
When you style :focus, you're styling every focus event — keyboard, mouse click, programmatic. This leads to a common complaint: "Why does my button have an ugly focus ring when I click it with my mouse?"
:focus-visible solves this. It only applies focus styles when the browser determines the user is navigating via keyboard (or needs a visible focus indicator). Mouse clicks typically don't trigger :focus-visible.
/* BAD: Shows focus ring on mouse click too */
button:focus {
outline: 2px solid var(--color-accent);
}
/* GOOD: Only shows focus ring for keyboard navigation */
button:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
/* Remove default outline, rely on focus-visible */
button:focus:not(:focus-visible) {
outline: none;
}
:focus-visible is supported in all modern browsers. The browser uses heuristics to decide when to apply it — generally, Tab and Shift+Tab triggers it, mouse clicks do not. For text inputs, :focus-visible is always applied because typing requires a visible cursor position.
The key insight: never remove focus outlines globally. This is one of the most damaging accessibility mistakes on the web. If you want to customize the appearance, use :focus-visible to show a styled indicator for keyboard users while hiding it for mouse users.
Skip Links
Imagine you're a keyboard user on a complex site. You press Tab to navigate. First you go through the logo, then the main nav (Home, About, Courses, Blog, Contact), then the search bar, then the user menu. That's 8-10 Tab presses before you reach the actual page content. Now imagine doing that on every single page.
Skip links solve this. They're links that are visually hidden but appear when focused, allowing keyboard users to jump directly to the main content.
<body>
<!-- First focusable element on the page -->
<a href="#main-content" class="skip-link">
Skip to main content
</a>
<nav><!-- lots of nav items --></nav>
<main id="main-content" tabindex="-1">
<!-- Page content -->
</main>
</body>
.skip-link {
position: absolute;
top: -100%;
left: 16px;
padding: 8px 16px;
background: var(--color-bg);
color: var(--color-text);
border: 2px solid var(--color-accent);
border-radius: 4px;
z-index: 1000;
font-size: 0.875rem;
}
.skip-link:focus {
top: 16px;
}
Notice two things:
- The skip link is the first focusable element in the DOM, so it's the first thing a Tab press reaches
- The
mainelement hastabindex="-1"so the skip link can send focus to it (non-interactive elements are not focusable by default)
Multiple skip links
Large applications sometimes benefit from multiple skip links — "Skip to main content", "Skip to search", "Skip to footer". GitHub uses this pattern. Each skip link targets a landmark with tabindex="-1". Group them in a list at the top of the page so they appear sequentially on first Tab press.
Keyboard Patterns Beyond Tab
Tab is only for moving between independent interactive elements. Inside composite widgets — toolbars, tab lists, menus, tree views, listboxes — the keyboard pattern changes completely. This is where most developers get keyboard navigation wrong.
The Principle
The WAI-ARIA Authoring Practices define specific keyboard patterns for each widget type. The core idea: Tab moves between widgets, arrow keys move within a widget.
Think of it like a spreadsheet. Tab moves you to the next cell. Arrow keys move within the current cell's dropdown.
Common Keyboard Patterns
Enter and Space — Activation
Enteractivates links and buttonsSpaceactivates buttons and toggles checkboxes- Links should not activate on Space (this is a real difference between links and buttons)
Escape — Dismissal
- Close modals, dropdowns, popovers, tooltips
- Return focus to the element that triggered the dismissed widget
Arrow Keys — Navigation Within Widgets
| Widget | Keys | Behavior |
|---|---|---|
| Tab list | Left/Right | Switch between tabs |
| Menu | Up/Down | Move between menu items |
| Tree view | Up/Down, Left/Right | Navigate and expand/collapse |
| Listbox | Up/Down | Select option |
| Toolbar | Left/Right | Move between tools |
| Date picker | Arrow keys | Navigate days/weeks |
Home / End
- Move to first/last item in a list, menu, or tab panel
Example: Tab List Keyboard Pattern
<div role="tablist" aria-label="Settings">
<button role="tab" aria-selected="true" id="tab-1"
aria-controls="panel-1" tabindex="0">
General
</button>
<button role="tab" aria-selected="false" id="tab-2"
aria-controls="panel-2" tabindex="-1">
Security
</button>
<button role="tab" aria-selected="false" id="tab-3"
aria-controls="panel-3" tabindex="-1">
Notifications
</button>
</div>
Notice: only the active tab has tabindex="0". The others have tabindex="-1". This means:
- Tab lands on the active tab, then skips the entire tab list to the next widget
- Arrow keys move between tabs within the list
- The user does not have to Tab through every single tab — they Tab once into the widget, use arrows to navigate, then Tab out
This pattern is called roving tabindex, and it's the most important keyboard pattern for composite widgets.
The Roving tabindex Pattern
Roving tabindex means exactly one element in a group has tabindex="0" (the "rover"), and all others have tabindex="-1". When the user presses an arrow key, you move tabindex="0" to the next element and focus it.
function handleKeyDown(event, items, currentIndex) {
let nextIndex = currentIndex;
switch (event.key) {
case 'ArrowRight':
case 'ArrowDown':
nextIndex = (currentIndex + 1) % items.length;
break;
case 'ArrowLeft':
case 'ArrowUp':
nextIndex = (currentIndex - 1 + items.length) % items.length;
break;
case 'Home':
nextIndex = 0;
break;
case 'End':
nextIndex = items.length - 1;
break;
default:
return;
}
event.preventDefault();
items[currentIndex].setAttribute('tabindex', '-1');
items[nextIndex].setAttribute('tabindex', '0');
items[nextIndex].focus();
}
Key details of roving tabindex:
- Arrow keys wrap around — pressing Right on the last item moves to the first (using modulo)
- Home/End jump to first/last item
- Only the focused item has tabindex 0 — so Tab moves out of the widget, not through every item
- Focus follows the roving tabindex — calling
.focus()after updating the attribute
Why Not Just Use Tab for Everything?
Imagine a toolbar with 20 buttons. If each one is a tab stop, a keyboard user has to press Tab 20 times to get past the toolbar. With roving tabindex, they Tab once to enter the toolbar, arrow through the buttons, then Tab once to leave. That's 2 Tab presses instead of 20.
This principle — Tab between widgets, arrows within widgets — is what makes keyboard navigation scalable.
Key Event Handling: keydown, Not keypress
When handling keyboard events, always use keydown. Here's why:
keypressis deprecated. It does not fire for non-character keys (Escape, arrows, Tab, Enter in some cases). Browsers are inconsistent about which keys trigger it. Don't use it.keydownfires for every key, including modifier keys. It fires before the browser's default action, so you can callpreventDefault()to stop it.keyupfires after the key is released. Useful for some cases (preventing repeated actions on key hold), butkeydownis the primary handler for keyboard navigation.
element.addEventListener('keydown', (event) => {
switch (event.key) {
case 'Enter':
case ' ':
event.preventDefault();
activateItem();
break;
case 'Escape':
closeWidget();
restoreFocus();
break;
case 'ArrowDown':
event.preventDefault();
focusNextItem();
break;
}
});
Important: always use event.key (returns 'Enter', 'Escape', 'ArrowDown'), not event.keyCode (returns numbers like 13, 27, 40). keyCode is deprecated and inconsistent across keyboard layouts.
Forgetting event.preventDefault() on arrow keys inside a scrollable container causes the page to scroll while you're trying to navigate within the widget. Always prevent default on keys you're handling — otherwise the browser's default behavior (scroll, form submit, link follow) will fire alongside your custom behavior.
Focus Trapping in Modals
When a modal dialog is open, Tab should cycle only through focusable elements inside the modal — it should never escape to elements behind the modal. This is called a focus trap.
function trapFocus(modalElement) {
const focusableSelectors = [
'a[href]',
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
].join(', ');
const focusables = modalElement.querySelectorAll(focusableSelectors);
const firstFocusable = focusables[0];
const lastFocusable = focusables[focusables.length - 1];
modalElement.addEventListener('keydown', (event) => {
if (event.key !== 'Tab') return;
if (event.shiftKey) {
if (document.activeElement === firstFocusable) {
event.preventDefault();
lastFocusable.focus();
}
} else {
if (document.activeElement === lastFocusable) {
event.preventDefault();
firstFocusable.focus();
}
}
});
}
And when the modal closes, focus must return to the element that opened it:
function openModal(triggerElement) {
const modal = document.getElementById('modal');
modal.style.display = 'block';
modal.querySelector('[tabindex="-1"]').focus();
modal.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
modal.style.display = 'none';
triggerElement.focus();
}
});
}
The HTML dialog element with the showModal() method handles focus trapping natively — it traps Tab, handles Escape to close, and prevents interaction with content behind the dialog. If you can use it, do. It is supported in all modern browsers and eliminates the need for custom focus trap code.
Putting It All Together: An Accessible Dropdown Menu
Let's combine everything — roving tabindex, arrow key navigation, Escape to close, focus management:
<div class="menu-container">
<button
aria-haspopup="true"
aria-expanded="false"
id="menu-trigger">
Options
</button>
<ul role="menu" aria-labelledby="menu-trigger" hidden>
<li role="menuitem" tabindex="-1">Edit</li>
<li role="menuitem" tabindex="-1">Duplicate</li>
<li role="menuitem" tabindex="-1">Delete</li>
</ul>
</div>
const trigger = document.getElementById('menu-trigger');
const menu = document.querySelector('[role="menu"]');
const items = menu.querySelectorAll('[role="menuitem"]');
trigger.addEventListener('keydown', (event) => {
if (event.key === 'Enter' || event.key === ' '
|| event.key === 'ArrowDown') {
event.preventDefault();
openMenu();
}
});
function openMenu() {
menu.hidden = false;
trigger.setAttribute('aria-expanded', 'true');
items[0].focus();
}
function closeMenu() {
menu.hidden = true;
trigger.setAttribute('aria-expanded', 'false');
trigger.focus();
}
menu.addEventListener('keydown', (event) => {
const currentIndex = Array.from(items).indexOf(
document.activeElement
);
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
items[(currentIndex + 1) % items.length].focus();
break;
case 'ArrowUp':
event.preventDefault();
items[
(currentIndex - 1 + items.length) % items.length
].focus();
break;
case 'Home':
event.preventDefault();
items[0].focus();
break;
case 'End':
event.preventDefault();
items[items.length - 1].focus();
break;
case 'Escape':
closeMenu();
break;
case 'Enter':
case ' ':
event.preventDefault();
items[currentIndex].click();
closeMenu();
break;
}
});
This dropdown follows every keyboard pattern from the WAI-ARIA Authoring Practices:
- Enter/Space/ArrowDown on the trigger opens the menu and focuses the first item
- Arrow keys navigate between items (with wrapping)
- Home/End jump to first/last item
- Enter/Space on an item activates it and closes the menu
- Escape closes the menu and returns focus to the trigger
- Tab is not used within the menu — it should close the menu and move to the next widget
| What developers do | What they should do |
|---|---|
| Using positive tabindex values to control tab order Positive tabindex creates a separate focus sequence that runs before all natural tab stops, making navigation unpredictable and unmaintainable | Fix the DOM order to match the desired tab order. Only use tabindex 0 and tabindex -1 |
| Removing focus outlines globally Removing focus outlines makes it impossible for keyboard users to see which element has focus — they navigate completely blind (WCAG 2.4.7 violation) | Use :focus-visible to show styled focus indicators for keyboard users only |
| Making every element in a widget a Tab stop A toolbar with 20 buttons should be 1 Tab stop, not 20. Tab moves between widgets, arrow keys move within widgets | Use roving tabindex: one Tab stop per widget, arrow keys to navigate within |
| Using keypress events for keyboard navigation keypress is deprecated, does not fire for non-character keys (Escape, arrows), and has inconsistent browser behavior. keydown fires for all keys | Use keydown with event.key instead of event.keyCode |
| Closing a modal or dropdown without returning focus to the trigger Without focus restoration, keyboard users lose their place on the page and have to re-Tab through everything to find where they were | Always call triggerElement.focus() when dismissing an overlay |
| Using a clickable div instead of a button element A clickable div is invisible to keyboard users (not focusable) and screen readers (no semantic role). Native button gives you focusability, Enter/Space activation, and correct semantics for free | Use a native button element for interactive controls |
- 1Tab order follows DOM order, not visual order. CSS reordering (Flexbox, Grid) does not change tab order.
- 2Only use tabindex 0 (add to tab order) and tabindex -1 (programmatic focus only). Never use positive values.
- 3Use :focus-visible for keyboard-only focus styles. Never globally remove focus outlines.
- 4Tab moves between widgets, arrow keys move within widgets. This is the roving tabindex pattern.
- 5Always handle keydown, not keypress. Use event.key, not event.keyCode. Both keypress and keyCode are deprecated.
- 6When dismissing modals or dropdowns via Escape, always return focus to the trigger element.
- 7Skip links let keyboard users bypass repetitive navigation. They should be the first focusable element in the DOM.