Skip to content

ARIA & Keyboard Patterns

Custom components need to behave like their native counterparts. ARIA attributes tell assistive technologies what a component is and what state it’s in. Keyboard patterns let users interact without a mouse. Together, they make custom UI accessible.

This page provides both human-readable guidance and machine-parseable specifications for AI agents and design systems.


Use native HTML elements when possible. A <button> is automatically accessible. A <div onclick> needs extensive work to match it. ARIA is for filling gaps, not replacing good HTML.

“No ARIA is better than bad ARIA.” — W3C WAI

Native elements provide keyboard support, focus management, and screen reader announcements for free. When you must build custom widgets, follow the patterns in this guide exactly.


RoleRequired AttributesKeyboard SupportContainer Relationship
buttonaccessible nameEnter, SpaceNone
linkaccessible name, href equivalentEnterNone
checkboxaria-checkedSpaceNone
radioaria-checkedArrow keys, Spaceradiogroup
tabaria-selectedArrow keystablist
tabpanelaria-labelledbyTab (into)linked to tab
menuitemaccessible nameEnter, Space, Arrowsmenu or menubar
optionaria-selectedArrow keys, Enterlistbox
treeitemaria-expanded (if parent)Arrow keystreegroup
dialogaria-labelledby, aria-modalTab (trapped), EscapeNone
alertdialogaria-labelledby, aria-describedbyTab (trapped), EscapeNone
slideraria-valuenow, aria-valuemin, aria-valuemaxArrow keys, Home, EndNone
comboboxaria-expanded, aria-controlsArrow keys, Enter, Escapecontrols listbox
Element TypePrimary MethodFallback MethodsWCAG
Button with textText contentaria-label, aria-labelledby4.1.2
Icon buttonaria-labelaria-labelledby, visually hidden text4.1.2
Input fieldAssociated <label>aria-label, aria-labelledby1.3.1, 4.1.2
Landmark regionaria-label or aria-labelledby1.3.1
Dialogaria-labelledby (to heading)aria-label4.1.2
Imagealt attributearia-label, aria-labelledby1.1.1
RequirementSpecificationWCAG
Minimum contrast3:1 against adjacent colors2.4.7, 2.4.11
Minimum area1px solid outline OR equivalent2.4.11
Enhanced area2px solid outline, offset for visibility2.4.12
Never hiddenoutline: none requires custom replacement2.4.7
VisibilityFocus indicator must not be obscured2.4.11

rules:
# Role and Name Validation
- id: aria-role-valid
severity: error
check: "role attribute value is a valid WAI-ARIA role"
selector: "[role]"
wcag: "4.1.2 AA"
- id: aria-name-required
severity: error
check: "Interactive elements have accessible name (text content, aria-label, or aria-labelledby)"
selector: "button, a[href], input, select, textarea, [role='button'], [role='link'], [role='checkbox'], [role='tab'], [role='menuitem']"
wcag: "4.1.2 AA"
- id: aria-button-name
severity: error
check: "Buttons have accessible name (text content, aria-label, aria-labelledby, or title)"
selector: "button, [role='button']"
wcag: "4.1.2 AA"
- id: aria-icon-button-label
severity: error
check: "Icon-only buttons have aria-label or visually hidden text"
selector: "button:not(:has(visible-text)), [role='button']:not(:has(visible-text))"
wcag: "4.1.2 AA"
# State Validation
- id: aria-checkbox-state
severity: error
check: "checkbox role has aria-checked attribute"
selector: "[role='checkbox']"
wcag: "4.1.2 AA"
- id: aria-expanded-state
severity: error
check: "Expandable controls have aria-expanded (true/false)"
selector: "[aria-haspopup], [aria-controls]:has(+ [hidden]), button:controls-disclosure"
wcag: "4.1.2 AA"
- id: aria-selected-state
severity: error
check: "Selectable items (tabs, options) have aria-selected"
selector: "[role='tab'], [role='option']"
wcag: "4.1.2 AA"
- id: aria-current-navigation
severity: warning
check: "Current page in navigation uses aria-current='page'"
selector: "nav a[href]:current-page"
wcag: "1.3.1 AA"
# Keyboard Validation
- id: keyboard-focusable
severity: error
check: "Interactive elements are keyboard focusable (tabindex >= -1 for custom, or native)"
selector: "[role='button'], [role='link'], [role='checkbox'], [role='tab'], [role='menuitem'], [onclick], [onkeydown]"
wcag: "2.1.1 AA"
- id: keyboard-operable
severity: error
check: "Custom interactive elements respond to Enter/Space (buttons) or appropriate keys"
selector: "[role='button']:not(button), [role='link']:not(a)"
wcag: "2.1.1 AA"
- id: focus-visible
severity: error
check: "Keyboard focus indicator is visible"
selector: ":focus-visible"
wcag: "2.4.7 AA"
note: "WCAG 2.4.13 AAA specifies indicator area and a ≥3:1 change of contrast"
- id: focus-not-obscured
severity: error
check: "Focused element not entirely hidden by other content"
selector: ":focus"
wcag: "2.4.11 AA"
# Dialog Validation
- id: dialog-label
severity: error
check: "Dialog has aria-labelledby pointing to heading or aria-label"
selector: "[role='dialog'], [role='alertdialog'], dialog"
wcag: "4.1.2 AA"
- id: dialog-focus-trap
severity: error
check: "Modal dialog traps focus (Tab cycles within, no escape to background)"
selector: "[role='dialog'][aria-modal='true'], dialog[open]"
wcag: "2.4.3 AA"
- id: dialog-escape
severity: warning
check: "Dismissible dialog closes on Escape key"
selector: "[role='dialog'], dialog"
wcag: "2.1.1 AA"
- id: dialog-focus-return
severity: error
check: "Focus returns to trigger element when dialog closes"
selector: "[role='dialog'], dialog"
wcag: "2.4.3 AA"
# Live Region Validation
- id: live-region-appropriate
severity: warning
check: "aria-live='assertive' and role='alert' used sparingly (interrupts user)"
selector: "[aria-live='assertive'], [role='alert']"
wcag: "4.1.3 AA"
- id: status-message
severity: warning
check: "Status messages use aria-live='polite' or role='status'"
selector: ".status-message, .toast:not(.error)"
wcag: "4.1.3 AA"

Every interactive element needs three things:

What kind of thing is this? Roles tell assistive technology how to treat an element.

<!-- Basic roles -->
<div role="button">Click me</div>
<div role="tab">Settings</div>
<div role="dialog" aria-modal="true">...</div>
<!-- Container roles -->
<div role="tablist">
<button role="tab">Tab 1</button>
</div>
<ul role="menu">
<li role="menuitem">Option 1</li>
</ul>

Role categories:

  • Widget roles: button, checkbox, link, menuitem, option, radio, slider, tab, treeitem
  • Composite roles: combobox, grid, listbox, menu, menubar, radiogroup, tablist, tree
  • Document structure: article, cell, columnheader, definition, figure, group, heading, img, list, listitem, row, rowgroup, table
  • Landmark roles: banner, complementary, contentinfo, form, main, navigation, region, search

What is this specific thing called? Screen readers announce this.

<!-- Text content provides name -->
<button>Save Document</button>
<!-- aria-label for icon buttons -->
<button aria-label="Close">
<svg><!-- X icon --></svg>
</button>
<!-- aria-labelledby references another element -->
<h2 id="dialog-title">Confirm Delete</h2>
<div role="dialog" aria-labelledby="dialog-title">...</div>
<!-- Multiple sources via aria-labelledby -->
<span id="action">Delete</span>
<span id="item">user.jpg</span>
<button aria-labelledby="action item"><!-- "Delete user.jpg" --></button>

Name computation priority (simplified):

  1. aria-labelledby (highest)
  2. aria-label
  3. Native label (<label>, alt, text content)
  4. title (lowest—avoid as sole source)

What’s happening with this thing right now?

<!-- Expanded/collapsed -->
<button aria-expanded="true" aria-controls="menu1">Menu</button>
<!-- Selected -->
<div role="tab" aria-selected="true">Active Tab</div>
<!-- Checked (checkbox, radio, switch) -->
<div role="checkbox" aria-checked="true">Agree to terms</div>
<div role="checkbox" aria-checked="mixed">Select all (partial)</div>
<!-- Invalid input -->
<input aria-invalid="true" aria-describedby="error-msg">
<span id="error-msg">Email format is invalid</span>
<!-- Disabled vs aria-disabled -->
<button disabled>Can't click (removed from tab order)</button>
<button aria-disabled="true">Can't activate (still focusable)</button>
<!-- Busy/loading -->
<div role="status" aria-busy="true">Loading...</div>
<!-- Current item in navigation -->
<a href="/about" aria-current="page">About</a>

IF component is a menu or menubar:
USE menu keyboard pattern (arrow navigation, type-ahead)
IF component is tabs:
USE tab keyboard pattern (arrows between tabs, Tab into panel)
IF component is a tree:
USE tree keyboard pattern (arrows + expand/collapse)
IF component is a listbox or combobox dropdown:
USE listbox keyboard pattern (arrows, type-ahead, Enter to select)
IF component is a dialog:
USE dialog keyboard pattern (focus trap, Escape to close)
IF component is a single toggle (checkbox, switch):
USE Space to toggle
IF component is a button:
USE Enter and Space to activate
IF component is a slider:
USE Arrow keys for increment, Home/End for min/max

Applies to: menu, menubar, dropdown menus, context menus

KeyAction
Arrow DownMove to next item (or first if on last)
Arrow UpMove to previous item (or last if on first)
Arrow RightOpen submenu (if present), or move to next menubar item
Arrow LeftClose submenu, or move to previous menubar item
HomeMove to first item
EndMove to last item
Enter / SpaceActivate current item
EscapeClose menu, return focus to trigger
A-Z (type-ahead)Move to next item starting with that letter
<button aria-haspopup="menu" aria-expanded="false" aria-controls="file-menu">
File
</button>
<ul id="file-menu" role="menu" hidden>
<li role="menuitem" tabindex="-1">New</li>
<li role="menuitem" tabindex="-1">Open</li>
<li role="separator"></li>
<li role="menuitem" tabindex="-1">Save</li>
</ul>

Applies to: dialog, alertdialog, modal windows, popovers

KeyAction
TabMove to next focusable element (cycles within dialog)
Shift+TabMove to previous focusable element (cycles within dialog)
EscapeClose dialog (if dismissible)
EnterActivate default button (if defined)

Focus management requirements:

// Opening a dialog
function openDialog(dialogEl, triggerEl) {
// 1. Store trigger for focus return
dialogEl.dataset.trigger = triggerEl.id;
// 2. Show dialog
dialogEl.hidden = false;
dialogEl.setAttribute('aria-modal', 'true');
// 3. Make background inert
document.querySelector('main').inert = true;
// 4. Move focus into dialog
const focusTarget = dialogEl.querySelector(
'[autofocus], h1, h2, [tabindex="-1"]'
) || dialogEl.querySelector('button, [href], input');
focusTarget?.focus();
// 5. Set up focus trap
trapFocus(dialogEl);
}
// Closing a dialog
function closeDialog(dialogEl) {
// 1. Hide dialog
dialogEl.hidden = true;
dialogEl.removeAttribute('aria-modal');
// 2. Remove inert from background
document.querySelector('main').inert = false;
// 3. Return focus to trigger
const triggerId = dialogEl.dataset.trigger;
document.getElementById(triggerId)?.focus();
}

Using native <dialog> element (recommended):

<dialog id="confirm-dialog" aria-labelledby="dialog-heading">
<h2 id="dialog-heading">Confirm Action</h2>
<p>Are you sure you want to proceed?</p>
<form method="dialog">
<button value="cancel">Cancel</button>
<button value="confirm" autofocus>Confirm</button>
</form>
</dialog>
<script>
const dialog = document.getElementById('confirm-dialog');
const trigger = document.getElementById('open-btn');
trigger.addEventListener('click', () => {
dialog.showModal(); // Automatically traps focus, handles Escape
});
dialog.addEventListener('close', () => {
trigger.focus(); // Return focus to trigger
});
</script>

Applies to: tablist, tab, tabpanel

KeyAction
Arrow Left/UpMove to previous tab
Arrow Right/DownMove to next tab
HomeMove to first tab
EndMove to last tab
TabMove focus from tabs into active tabpanel
Shift+TabMove focus from tabpanel back to active tab

Automatic vs manual activation:

  • Automatic: Tab content shows immediately when arrow key moves focus
  • Manual: User must press Enter/Space to show tab content after focusing
<div role="tablist" aria-label="Account settings">
<button role="tab" aria-selected="true" aria-controls="panel-1"
id="tab-1" tabindex="0">
Profile
</button>
<button role="tab" aria-selected="false" aria-controls="panel-2"
id="tab-2" tabindex="-1">
Security
</button>
<button role="tab" aria-selected="false" aria-controls="panel-3"
id="tab-3" tabindex="-1">
Notifications
</button>
</div>
<div role="tabpanel" id="panel-1" aria-labelledby="tab-1" tabindex="0">
<!-- Profile content -->
</div>
<div role="tabpanel" id="panel-2" aria-labelledby="tab-2" hidden>
<!-- Security content -->
</div>
<div role="tabpanel" id="panel-3" aria-labelledby="tab-3" hidden>
<!-- Notifications content -->
</div>

Applies to: tree, treeitem, file browsers, nested navigation

KeyAction
Arrow DownMove to next visible node
Arrow UpMove to previous visible node
Arrow RightExpand collapsed node, or move to first child
Arrow LeftCollapse expanded node, or move to parent
HomeMove to first node
EndMove to last visible node
EnterActivate node (open file, navigate)
SpaceToggle selection (if selectable)
*Expand all siblings
A-ZMove to next node starting with letter
<ul role="tree" aria-label="File browser">
<li role="treeitem" aria-expanded="true" tabindex="0">
Documents
<ul role="group">
<li role="treeitem" tabindex="-1">Resume.pdf</li>
<li role="treeitem" aria-expanded="false" tabindex="-1">
Projects
<ul role="group">
<li role="treeitem" tabindex="-1">Project-A.doc</li>
</ul>
</li>
</ul>
</li>
</ul>

Applies to: listbox, option, custom dropdowns, autocomplete

KeyAction
Arrow DownMove to next option
Arrow UpMove to previous option
HomeMove to first option
EndMove to last option
EnterSelect current option
EscapeClose listbox (in combobox), clear selection
A-ZType-ahead to matching option
<label id="color-label">Choose a color</label>
<div role="listbox" aria-labelledby="color-label" tabindex="0">
<div role="option" aria-selected="true" id="opt-1">Red</div>
<div role="option" aria-selected="false" id="opt-2">Green</div>
<div role="option" aria-selected="false" id="opt-3">Blue</div>
</div>

Only one item in a composite widget should be in the tab order at a time. Arrow keys move focus between items.

<!-- Only the focused item has tabindex="0" -->
<div role="tablist">
<button role="tab" tabindex="0">Tab 1</button> <!-- Focused -->
<button role="tab" tabindex="-1">Tab 2</button>
<button role="tab" tabindex="-1">Tab 3</button>
</div>
function rovingTabindex(container, items, direction) {
const currentIndex = items.findIndex(item => item.tabIndex === 0);
let newIndex;
if (direction === 'next') {
newIndex = (currentIndex + 1) % items.length;
} else {
newIndex = (currentIndex - 1 + items.length) % items.length;
}
// Update tabindex
items[currentIndex].tabIndex = -1;
items[newIndex].tabIndex = 0;
items[newIndex].focus();
}

In dialogs, keep Tab cycling within the dialog. The inert attribute (now baseline available in all browsers since April 2023) is the modern solution.

function trapFocus(container) {
const focusable = container.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstFocusable = focusable[0];
const lastFocusable = focusable[focusable.length - 1];
container.addEventListener('keydown', (e) => {
if (e.key !== 'Tab') return;
if (e.shiftKey && document.activeElement === firstFocusable) {
e.preventDefault();
lastFocusable.focus();
} else if (!e.shiftKey && document.activeElement === lastFocusable) {
e.preventDefault();
firstFocusable.focus();
}
});
}

Modern approach using inert:

function openModal(dialogEl) {
// Make everything except dialog inert
document.querySelectorAll('body > *:not(dialog)').forEach(el => {
el.inert = true;
});
dialogEl.showModal();
}
function closeModal(dialogEl) {
document.querySelectorAll('[inert]').forEach(el => {
el.inert = false;
});
dialogEl.close();
}

When a component closes, return focus to the element that triggered it.

class Dialog {
#triggerElement = null;
open(triggerEl) {
this.#triggerElement = triggerEl;
this.element.showModal();
}
close() {
this.element.close();
this.#triggerElement?.focus();
this.#triggerElement = null;
}
}
WHEN opening a dialog:
IF dialog has a form with an input that needs immediate attention:
FOCUS the first input field
ELSE IF dialog is a simple confirmation with brief message:
FOCUS the primary action button (e.g., "Confirm")
SET aria-describedby to reference message text
ELSE IF dialog has complex content or unexpected information:
FOCUS the dialog heading (with tabindex="-1")
OR FOCUS the dialog element itself (with tabindex="-1")
ELSE IF dialog has a close button and is purely informational:
FOCUS the close button
WHEN dialog trigger is removed from DOM:
FOCUS the nearest logical location (parent container, main content start)
OR FOCUS document.body as fallback
WHEN dialog was not user-initiated (e.g., timeout warning):
ENSURE user understands context (aria-describedby for explanation)
FOCUS the primary action

Keyboard Selection and Focus Completion Contract

Section titled “Keyboard Selection and Focus Completion Contract”

Use this contract when an interface includes single-select cards, radio groups, appointment or time choices, a multi-step flow, substantial dynamic content replacement, or a dialog. Retrieve it before implementation, then exercise it in the rendered interface before handoff.

  1. Prefer native controls. Use <button>, <a>, <input type="radio">, <input type="checkbox">, <fieldset>, and <legend> before creating a custom composite widget.
  2. Give one-of-many choices native-equivalent behaviour. Native radios in one named group must expose a group label, visible option labels, arrow-key movement that updates selection, Space selection where applicable, and one understandable selected value. A visual card may wrap or label the input; it must not replace the input semantics.
  3. Match the complete pattern when a custom widget is necessary. A custom radio group needs radiogroup and radio roles, programmatic names, aria-checked, roving tabindex, arrow-key movement that updates the checked option, Space selection, and a single active option. Partial ARIA is not an acceptable substitute.
  4. Keep focus visible and ordered. Do not remove the focus indicator. Tab order must follow the rendered task order, and selected styling must not hide the focused control.
  5. Do not confuse focus, selection, and commitment. Tabbing into or past a choice group must not silently select or submit. Within a radio group, arrow-key focus movement also changes the selected option; that expected selection must not silently commit or submit the choice. If selecting an option intentionally advances the flow, make that consequence clear before activation and place focus in the resulting content after the transition completes.
  6. Define a destination for substantial transitions. When a page or step is replaced, focus the new heading, main region, error summary, or confirmation status as appropriate and ensure it is perceivable in the viewport. Use a live region instead of moving focus for minor inline updates.
  7. Restore focus after transient interfaces. Move focus into an opened dialog, keep keyboard focus within a modal dialog, support Escape where dismissal is allowed, and return focus to the trigger or a documented logical fallback when it closes.

Exercise the rendered interface without a pointer at both a supported wide viewport and a supported narrow viewport.

CheckRequired evidence
Reach and orderTab and Shift+Tab reach every action in the expected task order; record any intentional exception.
Selection groupArrow keys move through the related choices, Space selects where applicable, and the selected value changes without losing the group context.
Visible focusIdentify the active element and visually confirm its focus indicator in default, selected, error, and disabled-adjacent states.
ActivationEnter and Space activate controls according to their native or documented pattern without duplicate submission.
Step transitionRecord the active element before and after the transition and confirm the new heading, main region, error summary, or status is perceivable.
Dialog lifecycleRecord initial focus, Tab containment, Escape behaviour, and the element focused after close.

For each failed or blocked check, report the state as failed or missing. Do not convert an unreachable state into a pass. Static source inspection, an automated accessibility scan, or a statement that the controls are semantic does not by itself satisfy this exercise.

Passing this contract demonstrates only the exercised keyboard and focus behaviour. It does not establish WCAG conformance, screen-reader usability, visual quality, or suitability for every input method. Do not add custom ARIA solely to satisfy the checklist, and do not move focus after every content update.


Announce dynamic content changes to screen readers without moving focus.

Attribute/RoleBehaviorUse Case
aria-live="polite"Announce after current speechStatus updates, non-urgent info
aria-live="assertive"Interrupt immediatelyTime-sensitive alerts
role="status"Implies aria-live="polite"Progress, results count
role="alert"Implies aria-live="assertive"Errors, warnings
role="log"Polite, new content appendedChat messages, activity feeds
role="timer"Not typically announcedCountdown displays
role="marquee"Not typically announcedStock tickers
<!-- Status message (polite) -->
<div role="status" aria-live="polite" aria-atomic="true">
<!-- Content updated via JavaScript -->
</div>
<!-- Error alert (assertive) -->
<div role="alert">
Error: Please enter a valid email address.
</div>
<!-- Search results count -->
<div role="status">
<span id="results-count">25 results found</span>
</div>

Key attributes:

  • aria-atomic="true": Announce entire region content, not just changes
  • aria-relevant: What types of changes to announce (additions, removals, text, all)
IF update is an error that blocks user progress:
USE role="alert" (assertive)
IF update is a form validation error:
USE aria-describedby on input + aria-invalid="true"
OPTIONALLY add role="alert" for immediate feedback
IF update is a success message after action:
USE role="status" (polite)
IF update is background information (loading, counts):
USE role="status" (polite)
IF update is time-sensitive (session timeout):
USE role="alert" (assertive)
DEFAULT to polite; assertive should be rare

<!-- Native button (preferred) -->
<button type="button" onclick="handleClick()">
Save Changes
</button>
<!-- Icon button with label -->
<button type="button" aria-label="Close dialog" onclick="closeDialog()">
<svg aria-hidden="true"><!-- X icon --></svg>
</button>
<!-- Custom button (when native isn't possible) -->
<div role="button"
tabindex="0"
onclick="handleClick()"
onkeydown="handleKeyDown(event)">
Save Changes
</div>
<script>
function handleKeyDown(event) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
handleClick();
}
}
</script>
<button aria-expanded="false" aria-controls="details-panel">
Show Details
</button>
<div id="details-panel" hidden>
<p>Additional details here...</p>
</div>
<script>
const btn = document.querySelector('[aria-expanded]');
const panel = document.getElementById('details-panel');
btn.addEventListener('click', () => {
const expanded = btn.getAttribute('aria-expanded') === 'true';
btn.setAttribute('aria-expanded', !expanded);
panel.hidden = expanded;
});
</script>
<label for="city-input">City</label>
<div class="combobox-container">
<input type="text"
id="city-input"
role="combobox"
aria-expanded="false"
aria-autocomplete="list"
aria-controls="city-listbox"
aria-activedescendant="">
<ul id="city-listbox" role="listbox" hidden>
<li role="option" id="opt-nyc">New York</li>
<li role="option" id="opt-la">Los Angeles</li>
<li role="option" id="opt-chi">Chicago</li>
</ul>
</div>
<a href="#main-content" class="skip-link">
Skip to main content
</a>
<!-- Later in document -->
<main id="main-content" tabindex="-1">
<!-- Main content -->
</main>
<style>
.skip-link {
position: absolute;
top: -40px;
left: 0;
padding: 8px;
background: #000;
color: #fff;
z-index: 100;
}
.skip-link:focus {
top: 0;
}
</style>

The WAI-ARIA 1.3 Working Draft (January 2024) introduces several new features:

RolePurposeExample Use
suggestionEditorial suggestionsTrack changes, comments
commentUser commentsDocument annotations
markHighlighted textSearch result highlighting
codeCode snippetsInline code (like <code>)
timeTime valuesDates, durations
imageImages (replaces img)Consistent with other roles
AttributePurposeBenefit
aria-descriptionAdditional descriptionMore context beyond label
aria-braillelabelBraille-specific labelOptimized for braille displays
aria-brailleroledescriptionBraille role descriptionContextual info for braille
  • aria-details now supports multiple IDrefs
  • Clarified aria-haspopup behavior
  • Enhanced accessibility tree definitions

Note: Browser and assistive technology support is gradual. Test before relying on 1.3 features.


  • Has appropriate role (native element or ARIA)
  • Has accessible name (visible text, aria-label, or aria-labelledby)
  • Is keyboard focusable (tabindex if custom)
  • Responds to appropriate keys (Enter/Space for buttons)
  • Has visible focus indicator (3:1 contrast minimum)
  • Has appropriate states (aria-expanded, aria-selected, etc.)

For Composite Widgets (Menus, Tabs, Trees)

Section titled “For Composite Widgets (Menus, Tabs, Trees)”
  • Uses roving tabindex (only one tabindex="0")
  • Arrow keys navigate between items
  • Home/End jump to first/last
  • Escape closes (for menus, dialogs)
  • Type-ahead works (for menus, listboxes)
  • Has role="dialog" (or uses <dialog>)
  • Has aria-modal="true" (or uses showModal())
  • Has aria-labelledby pointing to heading
  • Focus moves into dialog on open
  • Focus is trapped within dialog
  • Escape closes dialog
  • Focus returns to trigger on close
  • Background content is inert
  • Uses appropriate politeness (polite default, assertive rare)
  • Region exists in DOM before content changes
  • aria-atomic set correctly for context
  • Not overused (causes screen reader noise)

MistakeProblemSolution
role="button" without keyboardCustom buttons not operableAdd tabindex="0" + Enter/Space handler
Missing accessible nameScreen readers announce “button” onlyAdd aria-label or visible text
Focus escapes modalUsers can Tab to hidden contentUse inert on background or focus trap
aria-live="assertive" overuseConstant interruptionsDefault to polite; assertive for errors only
No focus returnUsers lost after dialog closesStore and restore trigger element
outline: none without replacementFocus invisible to keyboard usersCustom :focus-visible styles
Disabled via CSS onlyFocusable but inactive (confusing)Use disabled attribute or aria-disabled
Duplicate IDsaria-labelledby references wrong elementEnsure unique IDs
Missing expanded stateNo indication dropdown is openAdd aria-expanded to trigger
Tab key in tablistBreaks expected patternArrow keys for tabs; Tab exits to panel

The WebAIM Screen Reader Survey #10 (2024) found:

  • JAWS: 41% of respondents
  • NVDA: 38% of respondents
  • VoiceOver: Primary for macOS/iOS
  • Preferred browser: Chrome (52%), Edge (19%), Firefox (16%)

For each widget, verify:

  1. Role announced: “button”, “tab”, “menu”, etc.
  2. Name announced: The label text
  3. State announced: “expanded”, “selected”, “checked”
  4. Keyboard works: All patterns function correctly
  5. Focus mode active: For complex widgets, screen reader uses Focus mode (not Browse mode)
Screen ReaderEnter Focus ModeExit Focus Mode
JAWSEnter on widgetEscape or Num Pad +
NVDANVDA + SpaceEscape or NVDA + Space
VoiceOverVO + Shift + DownVO + Shift + Up

The WebAIM Screen Reader Survey #10 revealed navigation preferences:

  • Headings: Most common method for finding information
  • Landmarks/regions: Usage increased to 31.8% in 2024 (reversing decline)
  • Skip links: Still important for long pages

Design implication: Ensure proper heading hierarchy and landmark regions, not just ARIA widgets.

Persona Research: Wide Variation in User Behavior

Section titled “Persona Research: Wide Variation in User Behavior”

Research published in PMC found wide variations in screen reader user approaches:

  • Some users navigate linearly, skipping uninteresting sections
  • Others use headings primarily
  • Younger users may use regions more frequently
  • Users give up after a few minutes on difficult sites

Design implication: Support multiple navigation strategies; don’t assume one “correct” approach.

From the WebAIM survey:

  • 58% prefer mobile apps over websites (up from 51.8% in 2021)
  • Physical keyboards provide more control than touchscreens
  • Keyboard commands (accelerators) let users bypass sequential navigation

Design implication: Mobile apps need extra attention to accessibility; web apps should support keyboard power users.

The inert attribute reached baseline browser support in April 2023 (over 90% support):

  • Native <dialog> with showModal() automatically makes background inert
  • Can be applied to any element to remove from tab order and accessibility tree
  • Simpler than JavaScript focus traps for many use cases

Official Standards:

APG Design Patterns:

Practical Resources:

Research: