APG Patterns
ๆ—ฅๆœฌ่ชž
ๆ—ฅๆœฌ่ชž
โŒจ๏ธ

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

ValueBehavior
0Included in tab order at its DOM position
-1Focusable 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 TypeRecommendation When Disabled
Standalone elementsMake non-focusable
Listbox optionsKeep focusable
Menu itemsKeep focusable
TabsKeep focusable
Tree itemsKeep 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 tabindex values
<div role="toolbar" aria-label="Text formatting">
<button tabindex="0">Bold</button>
<button tabindex="-1">Italic</button>
<button tabindex="-1">Underline</button>
</div>
Initial state: only the first button has tabindex="0" and is included in the tab order.
<div role="toolbar" aria-label="Text formatting">
<button tabindex="-1">Bold</button>
<button tabindex="0">Italic</button>
<button tabindex="-1">Underline</button>
</div>
After pressing the โ†’ key: the second button now has tabindex="0" and receives focus.

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-activedescendant attribute to the id of the active child
  • Update aria-activedescendant value 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>
Listbox using aria-activedescendant โ€” DOM focus stays on the ul, while option-2 (Banana) is the currently active item.

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

AspectRoving tabindexaria-activedescendant
DOM FocusMoves to each itemStays on container
ImplementationModerate complexitySlightly more complex
Screen reader supportWidely supportedSupport varies
Dynamic contentRequires tabindex managementOnly needs ID assignment
Recommended use casesToolbars, tablistsListboxes, 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:

  1. Collapse all focusable descendants into a single Tab stop โ€” even if multiple <button> elements are present, only one is reachable via Tab
  2. Enable arrow-key navigation between focusable children within the group
  3. Automatically manage roving tabindex โ€” the browser handles tabindex values internally
  4. 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>
The focusgroup attribute replaces manual roving tabindex management. The browser automatically collapses the buttons into a single Tab stop and enables arrow-key navigation.

Available Values

The focusgroup attribute accepts space-separated tokens to control navigation behavior:

ValueBehavior
toolbarCreates a focusgroup; defaults to inline (horizontal) arrow-key navigation
inlineOnly inline-axis (left/right in LTR) arrow keys navigate
blockOnly block-axis (up/down) arrow keys navigate; overrides toolbarโ€™s default inline
wrapFocus wraps from last item to first (and vice versa) instead of stopping at the edge
no-memoryTab always enters at the start, ignoring focus history
extendJoins a nested focusgroup into its ancestorโ€™s focusgroup as a single logical group
noneOpts 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>
Combining focusgroup values for different navigation patterns.

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>
Without extend, nested focusgroups are independent Tab stops. With extend, child items merge into the parent's arrow-key navigation.

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

No-memory Toolbar (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.

Basic Toolbar (focusgroup="toolbar")

Tab to enter the toolbar, then use arrow keys to navigate. Tab again to exit. Focus stops at the edges.

Wrapping Toolbar (focusgroup="toolbar wrap")

Use arrow keys. Focus wraps around from last to first and vice versa.

Vertical Toolbar (focusgroup="toolbar block")

Use arrow keys to navigate. do nothing. block overrides toolbar's default inline axis.

Comparison with JavaScript-based Approaches

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>
Progressive enhancement: use focusgroup where supported, fall back to JavaScript-based roving tabindex elsewhere.

Resources

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 the modal
  3. Trap Tab/Shift+Tab within the modal
  4. On close, restore focus to the saved element

Essential Key Bindings

KeyCommon Action
Enter / SpaceActivate buttons, select options
Arrow keysNavigate within composites
EscapeClose dialogs, cancel operations
Home / EndJump to first/last item
TabMove to next focusable element
Shift + TabMove 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 AvoidReason
Ctrl+N/T/WBrowser tab/window operations
Ctrl+PPrint dialog
Ctrl+FFind in page
Alt+letterMenu bar access
F1 - F12Browser 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, Meta for 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:

  1. Be able to be turned off
  2. Be remappable to different keys
  3. 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.

Resources