Difficulty: Intermediate
Keyboard accessibility is one of the most critical aspects of web accessibility. Many users cannot use a mouse - people with motor disabilities, power users, screen reader users, and anyone with a temporary injury all rely on keyboard navigation. WCAG 2.1.1 (Level A) requires that all functionality be available from a keyboard, and 2.1.2 (Level A) prohibits keyboard traps where a user cannot move focus away from a component using standard keystrokes.
The natural tab order of a page follows the DOM order. Interactive elements like links, buttons, and form inputs are focusable by default and appear in the tab order automatically. Pressing Tab moves focus forward, Shift+Tab moves backward, Enter activates links and buttons, and Space activates buttons and checkboxes. This built-in behavior is why using semantic HTML is so important - a <button> gets keyboard interaction for free, while a <div> styled as a button requires manual keyboard handling.
The tabindex attribute controls whether an element is focusable and its position in the tab sequence. A tabindex of 0 adds an element to the natural tab order based on its DOM position - use this when you need custom elements to be focusable. A tabindex of -1 makes an element programmatically focusable (via JavaScript's element.focus()) but removes it from the tab order - useful for elements that should only receive focus through scripts, like inactive tabs or off-screen content. Positive tabindex values (1, 2, 3...) force elements to the front of the tab order, but this is almost universally considered an anti-pattern because it creates a confusing and unpredictable navigation experience.
A keyboard trap occurs when a user can Tab into a component but cannot Tab out of it using normal keyboard interactions. This is a critical accessibility failure. The one exception is modal dialogs, where focus should be intentionally trapped within the dialog while it is open - but even then, pressing Escape must close the dialog and restore focus to the triggering element. Common causes of accidental keyboard traps include embedded third-party widgets, autoplay media players, and custom dropdown menus that capture keyboard events.
Skip links are hidden navigation links that become visible on focus and allow keyboard users to bypass repetitive content blocks like headers and navigation menus. WCAG 2.4.1 (Level A) requires a mechanism to skip repeated content. A typical implementation is an anchor link at the very beginning of the page that jumps to the main content area. The link is visually hidden off-screen by default and slides into view when it receives focus, so it does not affect the visual layout for mouse users.
<!-- tabindex="0": Added to natural tab order (DOM position) -->
<div tabindex="0" role="button" onclick="handleClick()">
Custom Button
</div>
<!-- tabindex="-1": Focusable via JS only, not in tab order -->
<div id="modal-content" tabindex="-1">
This panel can receive focus programmatically.
</div>
<!-- Positive tabindex: AVOID THIS (anti-pattern) -->
<!-- These would be focused in order 1, 2, 3 before any
natural tab order elements -->
<input tabindex="3" placeholder="Focused third" />
<input tabindex="1" placeholder="Focused first" />
<input tabindex="2" placeholder="Focused second" />
<!-- Good practice: Let DOM order determine tab order -->
<input placeholder="Focused first (naturally)" />
<input placeholder="Focused second (naturally)" />
<input placeholder="Focused third (naturally)" />
tabindex='0' makes custom elements keyboard-accessible in the natural flow. tabindex='-1' enables programmatic focus for scripted interactions. Never use positive tabindex values as they break the natural and expected tab order.
<!DOCTYPE html>
<html lang="en">
<head>
<style>
.skip-link {
position: absolute;
top: -100%;
left: 16px;
padding: 12px 24px;
background-color: #1a1a2e;
color: #ffffff;
font-size: 16px;
font-weight: 600;
text-decoration: none;
z-index: 9999;
transition: top 0.2s ease;
}
.skip-link:focus {
top: 16px;
}
</style>
</head>
<body>
<!-- Skip link must be the first focusable element -->
<a href="#main" class="skip-link">Skip to main content</a>
<header>
<nav>
<a href="/">Home</a>
<a href="/products">Products</a>
<a href="/about">About</a>
<a href="/contact">Contact</a>
</nav>
</header>
<main id="main" tabindex="-1">
<h1>Welcome to Our Site</h1>
<p>Main content starts here.</p>
</main>
</body>
</html>
The skip link is visually hidden off-screen but appears when it receives keyboard focus. The main element has tabindex='-1' so it can receive programmatic focus when the skip link is activated. This is the first focusable element in the DOM.
<!-- Making a custom dropdown keyboard accessible -->
<div class="dropdown">
<button
id="menu-button"
aria-haspopup="true"
aria-expanded="false"
aria-controls="menu-list"
>
Options
</button>
<ul id="menu-list" role="menu" hidden>
<li role="menuitem" tabindex="-1">Edit</li>
<li role="menuitem" tabindex="-1">Duplicate</li>
<li role="menuitem" tabindex="-1">Delete</li>
</ul>
</div>
<script>
const button = document.getElementById('menu-button');
const menu = document.getElementById('menu-list');
const items = menu.querySelectorAll('[role="menuitem"]');
button.addEventListener('keydown', (e) => {
if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openMenu();
items[0].focus();
}
});
menu.addEventListener('keydown', (e) => {
const current = document.activeElement;
const index = Array.from(items).indexOf(current);
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
items[(index + 1) % items.length].focus();
break;
case 'ArrowUp':
e.preventDefault();
items[(index - 1 + items.length) % items.length].focus();
break;
case 'Escape':
closeMenu();
button.focus();
break;
}
});
function openMenu() {
menu.hidden = false;
button.setAttribute('aria-expanded', 'true');
}
function closeMenu() {
menu.hidden = true;
button.setAttribute('aria-expanded', 'false');
}
</script>
Custom dropdown menus need keyboard support: Enter/Space/ArrowDown to open, ArrowUp/ArrowDown to navigate items, Escape to close and return focus to the trigger. Menu items use tabindex='-1' so they are focusable via script but not in the main tab order.
Tab Order, tabindex, Keyboard Traps, Skip Links, Focus Indicators