Keyboard-First Experiences
This guide provides actionable technical standards for building keyboard-accessible web and desktop apps that work for everyone—from users with disabilities to power users who prefer keyboard-first experiences.
Quick Start
The impactful changes you can implement:
- Use semantic HTML,
<button>
,<input>
,<a>
instead of<div>
. - Never remove focus outlines without providing clear alternatives.
- Add skip links to bypass navigation.
- Label all form controls with
<label>
elements. - Test with
Tab
key only, unplug your mouse.
Why Keyboard Accessibility Matters
Keyboard accessibility eliminates mouse dependency. It’s essential for users with motor impairments, limited mobility, or visual disabilities who navigate with keyboards and screen readers. Most assistive technologies emulate keyboard input, making this foundational to accessible design.
By prioritizing keyboard accessibility, you’ll solve accessibility issues for users with motor impairments, visual disabilities, and temporary injuries while meeting legal compliance requirements (WCAG
, ADA
), and also creating faster workflows for power users who prefer keyboard navigation.
Core Principles
An application is keyboard-accessible if it meets these four criteria:
Principle | Description |
---|---|
Reachable | All interactive elements must be reachable via the Tab key. |
Visible Focus | The element that currently has focus must have a clear visual indicator. |
Logical Order | The tab order must follow a logical sequence, consistent with the visual layout. |
Operable | Once focused, an element’s function must be triggerable with the keyboard (e.g., Enter , Space , Arrow Keys ). |
Technical Implementation
Use Semantic HTML
Using semantic HTML is the foundation of accessibility. Native interactive elements like <button>
, <input>
, <a>
, and <select>
are focusable by default (i.e., they have a tabindex of 0
) and have built-in keyboard behaviors.
Avoid creating interactive components from non-semantic elements like <div>
or <span>
. While you can make a <div>
clickable, it won’t be focusable by default and won’t be properly announced by screen readers without extra ARIA attributes.
Example: An inaccessible custom button
<!-- This is not focusable with a keyboard -->
<div class="burger">
<span class="line"></span>
<span class="line"></span>
<span class="line"></span>
</div>
Solution: Use a <button>
<!-- This is accessible by default -->
<button class="burger">
<span class="line"></span>
<span class="line"></span>
<span class="line"></span>
<span class="visually-hidden">Menu</span>
</button>
By using a <button>
, you get keyboard focus, Enter
/Space
activation, and proper screen reader announcements for free. You can use CSS to remove any default browser styling.
The tabindex
Attribute
Control element focusability with the tabindex
attribute.
Value | Meaning | Usage Example |
---|---|---|
tabindex="0" | Includes an element in the natural tab order. | Custom interactive elements (e.g., a <div> as a button) |
tabindex="-1" | Makes an element focusable via JavaScript (element.focus() ), but not in the tab order. | Managing focus in dynamic components like modals |
tabindex > 0 | Creates a separate tab order, overriding the natural DOM sequence. Not recommended. | Avoid using positive values |
Focus Indication
Never remove the browser’s default focus outline without providing a clear and high-contrast alternative. A visible focus indicator is essential for users to know where they are on the page. Use the :focus-visible
pseudo-class to show focus styles only during keyboard navigation.
/* Example of a clear, on-brand focus style */
:focus-visible {
outline: 2px solid #005fcc;
outline-offset: 2px;
border-radius: 4px;
}
ARIA for Custom Components
ARIA (Accessible Rich Internet Applications) provides semantics for custom widgets when no native HTML element is suitable.
- Rule: Prefer native HTML elements (
<button>
,<input>
,<select>
) whenever possible. - Use Case: When building custom components (e.g., a custom dropdown), use ARIA to define the element’s
role
(its purpose) andaria-*
attributes (its state). ARIA does not provide keyboard behavior, you must implement that with JavaScript.
JavaScript Event Handling
When adding keyboard support to custom components, use device-independent event handlers.
- Prefer
click
for activation: Theclick
event is triggered by mouse clicks,Enter
, andSpace
on buttons and links, making it more robust thanmousedown
ortouchstart
. - Use
keydown
for responsive actions: Usekeydown
for events that need to fire immediately, such as navigating items in a dropdown menu with arrow keys. - Use
keyup
for non-repeating actions: Usekeyup
for actions that should only fire once when a key is released, such as confirming a selection or closing a dialog. This prevents the event from firing multiple times if the user holds down the key.
Performance Note: Keyboard events can fire rapidly. Consider debouncing for expensive operations:
function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
Common Accessibility Patterns
Skip Links
- Problem: Long navigation menus force users to tab through many links to reach the main content.
- Solution: Implement a “skip link” as the first focusable element to allow users to bypass navigation blocks. The link is visually hidden until it receives focus.
<body>
<a href="#content" class="skip-link">Skip to main content</a>
<nav>
<!-- Navigation links -->
</nav>
<main id="content" tabindex="-1">
<!-- Content of the page -->
</main>
</body>
The tabindex="-1"
on <main>
allows it to be programmatically focused by the skip link, without adding it to the natural tab order.
.skip-link {
position: absolute;
top: -40px;
left: 0;
background: #000;
color: white;
padding: 8px;
z-index: 100;
}
.skip-link:focus {
top: 10px;
}
/* Ensure content is focusable for the link to work */
#content:focus {
outline: none;
}
Roving tabindex
- Problem: Components with many interactive children (e.g., a toolbar, a grid of images) create an excessive number of tab stops.
- Proposed Solution: Use the roving
tabindex
pattern.- Set
tabindex="0"
on the currently active item andtabindex="-1"
on all others. - The user tabs once to focus the component.
- Use JavaScript to listen for arrow keys to move focus between items, updating which element has
tabindex="0"
. - The
Tab
key moves focus out of the component.
- Set
<div id="toolbar" role="toolbar" aria-label="Text formatting">
<button type="button" role="button" tabindex="0">Bold</button>
<button type="button" role="button" tabindex="-1">Italic</button>
<button type="button" role="button" tabindex="-1">Underline</button>
</div>
const toolbar = document.getElementById('toolbar');
const buttons = toolbar.querySelectorAll('button');
toolbar.addEventListener('keydown', (e) => {
let currentIndex = Array.from(buttons).indexOf(document.activeElement);
if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
e.preventDefault();
buttons[currentIndex].setAttribute('tabindex', -1);
if (e.key === 'ArrowRight') {
currentIndex = (currentIndex + 1) % buttons.length;
} else if (e.key === 'ArrowLeft') {
currentIndex = (currentIndex - 1 + buttons.length) % buttons.length;
}
buttons[currentIndex].setAttribute('tabindex', 0);
buttons[currentIndex].focus();
}
});
Focus Trapping in Modals
- Problem: A modal opens, but focus can escape to the page behind it.
- Solution: When a modal is active, trap focus within it.
- When the modal opens, save a reference to the element that opened it and move focus to the first interactive element inside the modal.
- Listen for
keydown
events. IfTab
is pressed, check if the focus is on the first or last element. If so, manually move focus to the last or first element, respectively, creating a cycle. - The
Escape
key must close the modal. - Upon closing, return focus to the element that opened the modal.
class FocusTrap {
constructor(element) {
this.element = element;
this.focusableElements = element.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
this.firstFocusable = this.focusableElements[0];
this.lastFocusable = this.focusableElements[this.focusableElements.length - 1];
}
trap(e) {
if (e.key === 'Tab') {
if (e.shiftKey) {
if (document.activeElement === this.firstFocusable) {
e.preventDefault();
this.lastFocusable.focus();
}
} else {
if (document.activeElement === this.lastFocusable) {
e.preventDefault();
this.firstFocusable.focus();
}
}
}
if (e.key === 'Escape') {
this.close();
}
}
}
Note: For production use, consider libraries like
focus-trap
for vanilla JS orfocus-trap-react
for React.
Dropdown/Combobox Pattern
A keyboard-accessible dropdown menu with ARIA roles and proper focus management.
<div class="dropdown">
<button aria-haspopup="listbox" aria-expanded="false" id="dropdown-button">
Choose option
</button>
<ul role="listbox" aria-labelledby="dropdown-button" hidden>
<li role="option" tabindex="-1">Option 1</li>
<li role="option" tabindex="-1">Option 2</li>
<li role="option" tabindex="-1">Option 3</li>
</ul>
</div>
Live Regions for Notifications
Use ARIA live regions to announce dynamic content changes.
function showNotification(message, type = 'polite') {
const notification = document.createElement('div');
notification.setAttribute('aria-live', type);
notification.setAttribute('aria-atomic', 'true');
notification.className = 'visually-hidden';
notification.textContent = message;
document.body.appendChild(notification);
setTimeout(() => {
document.body.removeChild(notification);
}, 1000);
}
React Hook Example
A reusable hook for arrow key navigation and selection in custom lists or menus.
function useKeyboardNavigation(items, onSelect) {
const [activeIndex, setActiveIndex] = useState(0);
const handleKeyDown = useCallback((e) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setActiveIndex((prev) => (prev + 1) % items.length);
break;
case 'ArrowUp':
e.preventDefault();
setActiveIndex((prev) => (prev - 1 + items.length) % items.length);
break;
case 'Enter':
e.preventDefault();
onSelect(items[activeIndex]);
break;
}
}, [items, activeIndex, onSelect]);
return { activeIndex, handleKeyDown };
}
Form Accessibility
Accessible forms are critical for user interaction. Ensure that every part of a form can be completed using only the keyboard.
- Use Semantic HTML: Always use native form elements like
<form>
,<label>
,<input>
,<textarea>
,<select>
, and<button>
. These elements have built-in keyboard accessibility and are understood by screen readers. Avoid creating “fake” form controls out of<div>
s. - Label Everything: Every form control must have a corresponding
<label>
element, associated via thefor
attribute. This ensures that screen readers announce the purpose of each control when it receives focus. - Logical Order: The tab order must flow logically from one field to the next, matching the visual presentation. The default DOM order usually handles this correctly if the HTML is structured logically.
- Error Handling: When a user submits a form with errors, programmatically move focus to the first invalid field. The error message for each field should be clearly visible and programmatically linked to its input using the
aria-describedby
attribute. - Submission: Users must be able to submit the form by pressing
Enter
on the submit button or, in many cases, by pressingEnter
from within a text field.
Form Error Handling Example
A snippet that moves focus to the first invalid form field and uses ARIA live regions to announce errors.
function handleFormSubmit(e) {
e.preventDefault();
const form = e.target;
const firstInvalidField = form.querySelector(':invalid');
if (firstInvalidField) {
firstInvalidField.focus();
const errorMessage = firstInvalidField.getAttribute('aria-describedby');
if (errorMessage) {
document.getElementById(errorMessage).setAttribute('aria-live', 'assertive');
}
}
}
Standard Component Keyboard Interactions
Users expect standard widgets to behave predictably. The WAI-ARIA Authoring Practices provides the definitive guide for these patterns.
Tabs
Key | Action |
---|---|
Left /Right Arrows | Switch between tabs. |
Tab | Move focus away from the tab group. |
Home /End | Move to the first/last tab. |
Menus (Dropdowns)
Key | Action |
---|---|
Up /Down arrows | Navigate options. |
Enter /Return | Select an option and close. |
Escape | Close without selection. |
Accordions
Key | Action |
---|---|
Up /Down arrows | Move between headers. |
Enter /Space | Expand or collapse the focused panel. |
Home /End | Move to the first/last header. |
Single-Page Apps (SPAs)
On route changes, screen readers need to be notified and focus must be managed.
- Update Title: Change
document.title
to reflect the new view. - Manage Focus: Programmatically move focus to the main heading or container of the new content. This prevents the user’s focus from being reset to the top of the document.
- Announce Route Changes: Use an ARIA live region to announce the new page title to screen reader users.
<!-- Add this to your main App shell -->
<div class="visually-hidden" aria-live="polite" aria-atomic="true" id="route-announcer"></div>
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
function handleRouteChange(newRoute) {
// 1. Update document title
document.title = `My App - ${newRoute.name}`;
// 2. Announce to screen readers
const announcer = document.getElementById('route-announcer');
announcer.textContent = `Navigated to ${newRoute.name}`;
// 3. Manage focus
const mainContent = document.querySelector('#main-content');
if (mainContent) {
mainContent.setAttribute('tabindex', '-1');
mainContent.focus();
}
}
Testing Checklist
Manual testing is one of the best ways to confirm keyboard accessibility. To quickly simulate a keyboard-only experience, unplug your mouse.
- Can every interactive element be reached with
Tab
? - Is the focus indicator always visible and high-contrast?
- Is the tab order logical and intuitive?
- Can all elements be activated with
Enter
orSpace
? (Check component-specific keys like arrows for sliders). - Are there any “keyboard traps” where focus cannot escape a component without closing it?
- Does
Esc
consistently close modals, pop-ups, and menus? - Do skip links work as expected?
- Are visually hidden elements removed from the tab order?
Troubleshooting
Quick solutions to common keyboard accessibility issues and pitfalls.
Issue | Action |
---|---|
Focus disappears | Check if display: none or visibility: hidden is being used. Use clip or visually-hidden class instead. |
Tab order is wrong | Ensure DOM order matches visual order. Avoid positive tabindex values. |
Focus trap not working | Verify all focusable elements are included in the selector query. |
Skip link not working | Ensure target element has tabindex="-1" and can receive programmatic focus. |
To provide feedback or suggest improvements, please open a GitHub issue.
⋛⋋( ⊙◊⊙)⋌⋚