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
<!-- 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-activedescendant attribute to the id of the active child
  • Update aria-activedescendant value 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

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>

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

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