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
<div role="toolbar" aria-label="Text formatting">
<button tabindex="0">Bold</button>
<button tabindex="-1">Italic</button>
<button tabindex="-1">Underline</button>
</div> <div role="toolbar" aria-label="Text formatting">
<button tabindex="-1">Bold</button>
<button tabindex="0">Italic</button>
<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
<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>
<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>
The focusgroup Attribute (Emerging Standard)
The focusgroup HTML attribute is an emerging web standard that enables declarative arrow-key navigation without any JavaScript. Traditionally, implementing keyboard navigation for composite widgets (toolbars, tablists, grids, etc.) requires extensive JavaScript to manage roving tabindex or aria-activedescendant. The focusgroup attribute aims to let browsers handle this natively.
How It Works
Adding focusgroup to a container element tells the browser to:
- Collapse all focusable descendants into a single Tab stop โ even if multiple
<button>elements are present, only one is reachable via Tab - Enable arrow-key navigation between focusable children within the group
- Automatically manage roving tabindex โ the browser handles
tabindexvalues internally - Remember the last-focused item โ when a user Tabs away and back, focus returns to the previously focused item
<!-- Before: Manual roving tabindex (JavaScript required) -->
<div role="toolbar" aria-label="Formatting">
<button tabindex="0">Bold</button>
<button tabindex="-1">Italic</button>
<button tabindex="-1">Underline</button>
</div>
<!-- After: Declarative focusgroup (no JavaScript needed) -->
<div role="toolbar" aria-label="Formatting" focusgroup="toolbar">
<button>Bold</button>
<button>Italic</button>
<button>Underline</button>
</div> Available Values
The focusgroup attribute accepts space-separated tokens to control navigation behavior:
| Value | Behavior |
|---|---|
toolbar | Creates a focusgroup; defaults to inline (horizontal) arrow-key navigation |
inline | Only inline-axis (left/right in LTR) arrow keys navigate |
block | Only block-axis (up/down) arrow keys navigate; overrides toolbarโs default inline |
wrap | Focus wraps from last item to first (and vice versa) instead of stopping at the edge |
no-memory | Tab always enters at the start, ignoring focus history |
extend | Joins a nested focusgroup into its ancestorโs focusgroup as a single logical group |
none | Opts a subtree out of an ancestorโs focusgroup |
These values can be combined:
<!-- Horizontal toolbar that wraps -->
<div role="toolbar" focusgroup="toolbar wrap">
<button>Cut</button>
<button>Copy</button>
<button>Paste</button>
</div>
<!-- Vertical toolbar -->
<div role="toolbar" focusgroup="toolbar block"
aria-orientation="vertical">
<button>File</button>
<button>Edit</button>
<button>View</button>
</div> Nested Focusgroups and extend
When focusgroups are nested, each creates an independent navigation scope by default. The extend keyword merges a child focusgroup into its parent, so they act as a single logical group:
<!-- Independent nested focusgroups -->
<div focusgroup="toolbar" aria-label="Main toolbar">
<button>Save</button>
<button>Print</button>
<div role="group" focusgroup="toolbar" tabindex="0"
aria-label="Text formatting">
<!-- Separate scope: Tab to enter, arrows within -->
<button>Bold</button>
<button>Italic</button>
</div>
<button>Close</button>
</div>
<!-- Merged with extend -->
<div focusgroup="toolbar" aria-label="Flat toolbar">
<button>A1</button>
<div focusgroup="extend">
<!-- Same scope as parent: A1 โ B1 โ B2 โ A2 -->
<button>B1</button>
<button>B2</button>
</div>
<button>A2</button>
</div> Shadow DOM Integration
The focusgroup attribute works across Shadow DOM boundaries by default. A focusgroup declared on a shadow host includes focusable elements inside that hostโs shadow tree. You can opt out with focusgroup="none" inside the shadow tree if needed.
Key Conflict Handling
When focusable children like <input> or <textarea> exist inside a focusgroup, arrow keys are consumed by the interactive element for their native purpose (e.g., cursor movement). Tab or Shift+Tab provides an escape mechanism to re-enter the focusgroup navigation.
Interactive Demo
Your browser does not support the focusgroup attribute yet.
The focusgroup attribute is currently supported in Chromium-based browsers (Chrome/Edge 146+ origin trial, or 133+ with flag). The demos below show the intended markup, but arrow key navigation requires browser support.
Your browser supports the focusgroup attribute!
Try tabbing into each demo below and using arrow keys to navigate. Notice how the browser handles roving tabindex automatically โ no JavaScript needed.
focusgroup="toolbar no-memory")
Move to the 3rd+ button with arrow keys, Tab out, then Tab back in. With no-memory, focus should always return to the first button. If memory is retained, focus returns to the previously focused button.
focusgroup="toolbar")
Tab to enter the toolbar, then use ← → arrow keys to navigate. Tab again to exit. Focus stops at the edges.
focusgroup="toolbar wrap")
Use ← → arrow keys. Focus wraps around from last to first and vice versa.
focusgroup="toolbar block")
Use ↑ ↓ arrow keys to navigate. ← → do nothing. block overrides toolbar's default inline axis.
Comparison with JavaScript-based Approaches
| Aspect | Roving tabindex (JS) | aria-activedescendant (JS) | focusgroup (HTML) |
|---|---|---|---|
| JavaScript required | Yes | Yes | No |
| Tab stop management | Manual tabindex swapping | Container holds focus | Automatic |
| Arrow key handling | Custom keydown handler | Custom keydown handler | Built-in |
| Focus memory | Must implement manually | Must implement manually | Built-in (default behavior) |
| Wrap behavior | Must implement manually | Must implement manually | wrap keyword |
| Vertical navigation | Manual key direction switch | Manual key direction switch | block keyword |
| Browser support | All browsers | All browsers | Chromium 146+ (origin trial) |
| Screen reader support | Widely supported | Varies | Aligned with roving tabindex |
Roving tabindex (JS)
- JavaScript required
- Yes
- Tab stop management
- Manual tabindex swapping
- Arrow key handling
- Custom keydown handler
- Focus memory
- Must implement manually
- Wrap behavior
- Must implement manually
- Vertical navigation
- Manual key direction switch
- Browser support
- All browsers
- Screen reader support
- Widely supported
aria-activedescendant (JS)
- JavaScript required
- Yes
- Tab stop management
- Container holds focus
- Arrow key handling
- Custom keydown handler
- Focus memory
- Must implement manually
- Wrap behavior
- Must implement manually
- Vertical navigation
- Manual key direction switch
- Browser support
- All browsers
- Screen reader support
- Varies
focusgroup (HTML)
- JavaScript required
- No
- Tab stop management
- Automatic
- Arrow key handling
- Built-in
- Focus memory
- Built-in (default behavior)
- Wrap behavior
- wrap keyword
- Vertical navigation
- block keyword
- Browser support
- Chromium 146+ (origin trial)
- Screen reader support
- Aligned with roving tabindex
Browser Support and Progressive Enhancement
As of early 2026, the scoped focusgroup attribute is available in Chromium-based browsers (Chrome/Edge 146+) as an origin trial. An earlier unscoped prototype shipped behind a flag in Chrome 133. It is not yet supported in Firefox or Safari.
Recommended approach: Use focusgroup as a progressive enhancement alongside existing JavaScript-based keyboard navigation. This way, supported browsers get the native behavior while others fall back to your JavaScript implementation:
<div
role="toolbar"
aria-label="Formatting"
focusgroup="toolbar wrap"
>
<button>Bold</button>
<button>Italic</button>
<button>Underline</button>
</div>
<script>
// Only add JS-based roving tabindex if focusgroup is not supported
if (!('focusgroup' in document.createElement('div'))) {
// Fallback: initialize roving tabindex via JavaScript
initRovingTabIndex(toolbar, 'button');
}
</script> Resources
- Request for developer feedback: focusgroup โ Chrome for Developers
- Scoped Focusgroup Explainer โ Open UI
- focusgroup Explainer (original) โ Open UI
- WHATWG HTML Issue #11641 โ Focusgroup proposal
- Focusgroup Explainer โ Microsoft Edge
- Focusgroup Interactive Demos โ Microsoft Edge
Focus Trap for Modals
Keep focus within modal dialogs. On modal open:
- Save the element that had focus
- Move focus to the first focusable element in the modal
- Trap Tab/Shift+Tab within the modal
- On close, restore focus to the 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.