Developing a Keyboard Interface
Unlike native HTML form elements, browsers do not provide keyboard support for custom widgets. Authors must provide keyboard access.
This practice is documented in detail at Developing a Keyboard Interface - WAI-ARIA APG . Below we provide additional context specific to our pattern implementations.
Overview
Keyboard accessibility is essential for users who cannot use a mouse, including people with motor disabilities, blind users, and power users who prefer keyboard navigation. Unlike native HTML form elements, browsers do not provide built-in keyboard support for custom widgetsโauthors must implement it themselves.
Focus Management Fundamentals
There are three key principles for managing keyboard focus effectively:
- Visibility of the focus indicator: Users must be able to easily distinguish where the focus is. Using browser default focus indicators is recommended.
- Persistence of focus: There should always be an element that has focus. When closing dialogs or deleting items, focus must be moved to an appropriate element.
- Predictability of movement: Users should be able to easily guess where focus will land next. Movement patterns that follow reading order and consistency within sections are important.
Making Elements Focusable
<!-- Native focusable elements -->
<button>Click me</button>
<a href="/page">Link</a>
<input type="text" />
<!-- Making non-focusable elements focusable -->
<div role="button" tabindex="0">Custom button</div>
<!-- Removing from tab order (but still programmatically focusable) -->
<div tabindex="-1">Focusable via JavaScript only</div>
tabindex Values
| Value | Behavior |
|---|---|
0 | Included in tab order at its DOM position |
-1 | Focusable via JavaScript but not in tab order |
Positive integers (1+) | Avoid - Creates confusing tab order |
Focus vs Selection
Focus and selection are different concepts. Focus indicates โthe element currently being operated onโ and only one element can have focus at a time. Selection state (aria-selected="true"), on the other hand, can be held by multiple elements simultaneously.
In widgets like listboxes, trees, and tablists, focus and selection can exist separately. For example, in a multi-select listbox, multiple items can maintain their selected state while focus moves to a different item.
Visually, itโs important to clearly distinguish between the focus indicator and selection state styles.
Deciding When Selection Follows Focus
For single-selection widgets (tablists, single-select listboxes, etc.), thereโs a โselection follows focusโ pattern where selection state automatically changes when focus moves.
When appropriate:
- Tab panels already exist in the DOM and can be displayed immediately
- Users want to quickly review options
When not appropriate:
- Panel display requires network requests or page refresh
- Selection changes involve heavy processing
When selection follows focus is not appropriate, adopt a pattern where users explicitly change selection with Enter or Space key.
Focusability of Disabled Controls
By default, HTML disabled elements are excluded from the tab order. However, within composite widgets, itโs often recommended to keep disabled elements focusable.
| Element Type | Recommendation When Disabled |
|---|---|
| Standalone elements | Make non-focusable |
| Listbox options | Keep focusable |
| Menu items | Keep focusable |
| Tabs | Keep focusable |
| Tree items | Keep focusable |
Keeping disabled elements focusable allows screen reader users to recognize their existence and disabled state. On the other hand, making them non-focusable reduces keystrokes but may hide the elementโs existence.
Keyboard Navigation Patterns
Roving tabindex
For composite widgets like toolbars, tablists, and menus, the entire group is treated as a single Tab stop. Users press Tab to enter the group, arrow keys to navigate within, and Tab again to move to the next widget.
Core Principle
- Only one element in the group has
tabindex="0" - All other elements have
tabindex="-1" - When focus moves via arrow keys, swap the
tabindexvalues
<!-- Initial state: only first button has tabindex="0" -->
<div role="toolbar" aria-label="Text formatting">
<button tabindex="0">Bold</button>
<button tabindex="-1">Italic</button>
<button tabindex="-1">Underline</button>
</div>
<!-- After pressing โ key: second button now has tabindex="0" -->
<div role="toolbar" aria-label="Text formatting">
<button tabindex="-1">Bold</button>
<button tabindex="0">Italic</button>
<!-- focus is here -->
<button tabindex="-1">Underline</button>
</div>
Implementation Pattern
class RovingTabIndex {
constructor(container, selector) {
this.items = [...container.querySelectorAll(selector)];
this.currentIndex = 0;
this.init();
}
init() {
// First element gets tabindex="0", others get tabindex="-1"
this.items.forEach((item, index) => {
item.setAttribute('tabindex', index === 0 ? '0' : '-1');
});
// Set up keyboard events
this.items.forEach((item) => {
item.addEventListener('keydown', (e) => this.handleKeyDown(e));
});
}
handleKeyDown(event) {
let newIndex = this.currentIndex;
switch (event.key) {
case 'ArrowRight':
case 'ArrowDown':
newIndex = (this.currentIndex + 1) % this.items.length; // wrap around
break;
case 'ArrowLeft':
case 'ArrowUp':
newIndex = (this.currentIndex - 1 + this.items.length) % this.items.length;
break;
case 'Home':
newIndex = 0;
break;
case 'End':
newIndex = this.items.length - 1;
break;
default:
return; // don't handle other keys
}
event.preventDefault();
this.moveFocus(newIndex);
}
moveFocus(newIndex) {
// Remove current element from tab order
this.items[this.currentIndex].setAttribute('tabindex', '-1');
// Add new element to tab order and focus it
this.currentIndex = newIndex;
this.items[this.currentIndex].setAttribute('tabindex', '0');
this.items[this.currentIndex].focus();
}
}
Why Roving tabindex?
If every element in a composite widget had tabindex="0", users would Tab through each one individually. A tablist with 10 tabs would require 10 Tab presses to reach the next section.
With roving tabindex:
- Tab: Skip the entire widget in one step
- Arrow keys: Fine-grained navigation within the widget
This separation allows keyboard users to navigate pages efficiently.
Focus Management with aria-activedescendant
As an alternative to roving tabindex, you can use aria-activedescendant for focus management. With this approach, the container element always holds DOM focus, and the aria-activedescendant attribute indicates the currently active child element.
Core Principle
- The container element has
tabindex="0"and receives focus - Child elements do not have
tabindex(not directly focusable) - Set the containerโs
aria-activedescendantattribute to theidof the active child - Update
aria-activedescendantvalue when navigating with arrow keys
<!-- Listbox using aria-activedescendant -->
<ul role="listbox" tabindex="0" aria-activedescendant="option-2" aria-label="Select a fruit">
<li id="option-1" role="option">Apple</li>
<li id="option-2" role="option" aria-selected="true">Banana</li>
<!-- โ currently active -->
<li id="option-3" role="option">Orange</li>
</ul>
Implementation Pattern
class ActiveDescendant {
constructor(container, selector) {
this.container = container;
this.items = [...container.querySelectorAll(selector)];
this.currentIndex = 0;
this.init();
}
init() {
// Assign unique IDs to each item
this.items.forEach((item, index) => {
if (!item.id) {
item.id = `${this.container.id}-item-${index}`;
}
});
// Set initial active element
this.container.setAttribute('aria-activedescendant', this.items[0].id);
// Handle keyboard events on the container
this.container.addEventListener('keydown', (e) => this.handleKeyDown(e));
}
handleKeyDown(event) {
let newIndex = this.currentIndex;
switch (event.key) {
case 'ArrowDown':
newIndex = Math.min(this.currentIndex + 1, this.items.length - 1);
break;
case 'ArrowUp':
newIndex = Math.max(this.currentIndex - 1, 0);
break;
case 'Home':
newIndex = 0;
break;
case 'End':
newIndex = this.items.length - 1;
break;
default:
return;
}
event.preventDefault();
this.setActiveDescendant(newIndex);
}
setActiveDescendant(newIndex) {
// Update visual highlight
this.items[this.currentIndex].classList.remove('active');
this.items[newIndex].classList.add('active');
// Update aria-activedescendant
this.currentIndex = newIndex;
this.container.setAttribute('aria-activedescendant', this.items[newIndex].id);
}
}
Comparison with Roving tabindex
| Aspect | Roving tabindex | aria-activedescendant |
|---|---|---|
| DOM Focus | Moves to each item | Stays on container |
| Implementation | Moderate complexity | Slightly more complex |
| Screen reader support | Widely supported | Support varies |
| Dynamic content | Requires tabindex management | Only needs ID assignment |
| Recommended use cases | Toolbars, tablists | Listboxes, comboboxes |
When to Use Each Approach
Choose roving tabindex when:
- Broad screen reader support is required
- Simpler implementation is preferred
- Building toolbars, menus, or tablists
Choose aria-activedescendant when:
- Container has text input (combobox)
- Focus stays on the input, allowing users to continue typing while selecting from suggestions
- List has many items
- Items are dynamically added or removed
<!-- Combobox example -->
<div class="combobox">
<input
type="text"
role="combobox"
aria-expanded="true"
aria-haspopup="listbox"
aria-activedescendant="suggestion-2"
aria-controls="suggestions"
/>
<ul id="suggestions" role="listbox">
<li id="suggestion-1" role="option">Tokyo</li>
<li id="suggestion-2" role="option">Osaka</li>
<li id="suggestion-3" role="option">Nagoya</li>
</ul>
</div>
Focus Trap for Modals
Keep focus within modal dialogs:
// On modal open:
// 1. Save the element that had focus
// 2. Move focus to the first focusable element in modal
// 3. Trap Tab/Shift+Tab within modal
// 4. On close, restore focus to saved element
Essential Key Bindings
| Key | Common Action |
|---|---|
Enter / Space | Activate buttons, select options |
Arrow keys | Navigate within composites |
Escape | Close dialogs, cancel operations |
Home / End | Jump to first/last item |
Tab | Move to next focusable element |
Shift + Tab | Move to previous focusable element |
Implementing Shortcut Keys
Adding keyboard shortcuts can improve efficiency for power users. However, improper implementation can cause accessibility issues.
Combining with Modifier Keys
Shortcuts typically combine modifier keys (Ctrl, Alt, Shift) with other keys:
document.addEventListener('keydown', (event) => {
// Ctrl+S to save
if (event.ctrlKey && event.key === 's') {
event.preventDefault();
saveDocument();
}
// Ctrl+Shift+P to open command palette
if (event.ctrlKey && event.shiftKey && event.key === 'p') {
event.preventDefault();
openCommandPalette();
}
});
Avoiding Conflicts
Be careful not to conflict with browser, OS, or assistive technology shortcuts:
| Keys to Avoid | Reason |
|---|---|
Ctrl+N/T/W | Browser tab/window operations |
Ctrl+P | Print dialog |
Ctrl+F | Find in page |
Alt+letter | Menu bar access |
F1 - F12 | Browser or OS functions |
The aria-keyshortcuts Attribute
Use aria-keyshortcuts to communicate shortcuts to assistive technology:
<button aria-keyshortcuts="Control+S" onclick="save()">Save</button>
<button aria-keyshortcuts="Control+Shift+P" onclick="openPalette()">Command Palette</button>
Notation rules:
- Use
Control,Alt,Shift,Metafor modifier keys - Join keys with
+ - Separate multiple shortcuts with space
Problems with accesskey
Avoid using HTMLโs accesskey attribute:
<!-- Not recommended -->
<button accesskey="s">Save</button>
Issues:
- Modifier keys vary by browser and OS (Alt, Alt+Shift, Ctrl+Alt, etc.)
- Easily conflicts with existing shortcuts
- Hard for users to discover
- Internationalization issues (key positions vary by keyboard layout)
Shortcut Discoverability
Help users discover shortcuts:
<!-- Show shortcuts in tooltips or labels -->
<button title="Save (Ctrl+S)">
๐พ Save
<kbd class="shortcut">Ctrl+S</kbd>
</button>
<!-- Provide a keyboard shortcuts reference -->
<dialog id="shortcuts-help">
<h2>Keyboard Shortcuts</h2>
<dl>
<dt><kbd>Ctrl</kbd>+<kbd>S</kbd></dt>
<dd>Save document</dd>
<dt><kbd>Ctrl</kbd>+<kbd>Z</kbd></dt>
<dd>Undo</dd>
</dl>
</dialog>
Single-key Shortcut Considerations
Single-key shortcuts without modifiers (e.g., ? for help) can cause problems:
- Voice input users may accidentally trigger commands
- Can be triggered while typing in text fields
WCAG 2.1 Success Criterion 2.1.4 requires single-key shortcuts to either:
- Be able to be turned off
- Be remappable to different keys
- Only be active when the component has focus
// Example: shortcut only active when element has focus
toolbar.addEventListener('keydown', (event) => {
if (event.key === 'b' && !event.ctrlKey && !event.altKey) {
event.preventDefault();
toggleBold();
}
});
Implementation Tips
Visual Focus Indicator
Always provide a visible focus indicator:
/* Never do this without an alternative */
:focus {
outline: none;
}
/* Provide a clear focus style */
:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
Keyboard Event Handling
element.addEventListener('keydown', (event) => {
switch (event.key) {
case 'Enter':
case ' ': // Space
event.preventDefault();
activateElement();
break;
case 'ArrowDown':
event.preventDefault();
focusNextItem();
break;
case 'ArrowUp':
event.preventDefault();
focusPreviousItem();
break;
}
});
Prevent Scroll on Space
When Space activates an element, prevent default scrolling:
if (event.key === ' ') {
event.preventDefault();
// Handle activation
}
Common Pitfalls
Mouse-only Interactions
<!-- Bad: Only works with mouse -->
<div onclick="showMenu()">Menu</div>
<!-- Good: Works with keyboard too -->
<button type="button" onclick="showMenu()">Menu</button>
Focus Moving Off-screen
Ensure focus never moves to hidden or off-screen elements. When hiding content:
// Before hiding, move focus to a visible element
focusElement.focus();
hiddenElement.hidden = true;
Ignoring Screen Orientation
Support both horizontal and vertical arrow key navigation where appropriate, especially for widgets that may be oriented differently.