Accessible Component Patterns
Common interactive UI patterns require careful implementation to be accessible. This guide covers the correct HTML structure, keyboard interaction, and expected screen reader output for each pattern, along with how to verify your implementation using Speakable. These patterns complement manual testing with real assistive technology. Automated prediction catches structural issues early, while manual testing validates nuanced interactions that no tool can fully replicate. Use these patterns as a reference when building components, and run the provided Speakable commands to confirm your markup produces the expected announcements across NVDA, JAWS, VoiceOver, and Narrator.
Modal / Dialog
Modals interrupt the user's workflow by overlaying content that demands immediate attention. A correctly implemented modal traps focus inside its boundary, announces its purpose to screen readers, and returns focus to the triggering element on close. The native <dialog> element provides many of these behaviors for free, but custom implementations using role="dialog" need explicit management of focus trapping, backdrop interaction, and ARIA attributes. Getting this wrong means keyboard users can tab behind the modal into invisible content, and screen reader users hear nothing about the modal context they are inside.
<!-- Native dialog element (recommended) -->
<dialog id="confirm-dialog" aria-labelledby="dialog-title">
<h2 id="dialog-title">Confirm deletion</h2>
<p>Are you sure you want to delete this item? This action cannot be undone.</p>
<div class="dialog-actions">
<button autofocus>Cancel</button>
<button class="danger">Delete</button>
</div>
</dialog>
<!-- Custom implementation with role="dialog" -->
<div
role="dialog"
aria-modal="true"
aria-labelledby="custom-dialog-title"
class="modal-overlay"
>
<div class="modal-content">
<h2 id="custom-dialog-title">Confirm deletion</h2>
<p>Are you sure you want to delete this item?</p>
<button autofocus>Cancel</button>
<button>Delete</button>
</div>
</div>Keyboard Interaction
- Escape closes the dialog and returns focus to the trigger element
- Tab cycles through focusable elements inside the dialog only (focus trap)
- Shift+Tab cycles backwards through focusable elements inside the dialog
- Focus moves to the first focusable element (or
autofocuselement) when the dialog opens - When the dialog closes, focus returns to the element that triggered the dialog
Screen Reader Output
Common Mistakes
- No focus trap: keyboard users can Tab behind the modal into background content
- Missing
aria-labelledbyoraria-label: screen readers announce a generic "dialog" with no context - Not restoring focus on close: keyboard users lose their place in the page
- Missing
aria-modal="true": screen readers can still navigate to background content in virtual mode - Not trapping the Tab key: focus escapes into content behind an overlay the user cannot see
- Using
display: noneon the backdrop without hiding background content from the accessibility tree
Test with Speakable
speakable modal.html -f text -s all --selector "[role='dialog'], dialog"
Learn more about the dialog role on the ARIA Roles reference.
Dropdown Menu
Dropdown menus present a list of actions that appear when a user activates a trigger button. The WAI-ARIA menu pattern uses a button with aria-expanded to communicate the open/closed state, paired with a container using role="menu" that holds items marked with role="menuitem". Arrow key navigation within the menu mirrors the behavior users expect from native application menus, and Escape closes the menu and returns focus to the trigger. Without these semantics, screen reader users will not understand that a group of actions is contextually related or how to navigate them efficiently.
<div class="dropdown">
<button
aria-expanded="false"
aria-haspopup="menu"
aria-controls="actions-menu"
>
Actions
</button>
<ul id="actions-menu" role="menu" hidden>
<li role="menuitem" tabindex="-1">Edit</li>
<li role="menuitem" tabindex="-1">Duplicate</li>
<li role="menuitem" tabindex="-1">Archive</li>
<li role="separator"></li>
<li role="menuitem" tabindex="-1">Delete</li>
</ul>
</div>Keyboard Interaction
- Enter or Space on the trigger button opens the menu and moves focus to the first item
- ↓ moves focus to the next menu item
- ↑ moves focus to the previous menu item
- Escape closes the menu and returns focus to the trigger button
- Home moves focus to the first menu item
- End moves focus to the last menu item
- Enter on a menu item activates it and closes the menu
Screen Reader Output
When collapsed:
When expanded and focused on an item:
Common Mistakes
- Using
<a>links instead ofrole="menuitem": screen readers expect menu semantics for arrow key navigation - Missing
aria-expandedon the trigger: users cannot tell if the menu is open or closed - No
aria-haspopupattribute: screen readers do not announce the button opens a menu - Using
role="menu"for navigation: this role is for action menus, not site navigation - Not managing focus with
tabindex="-1"on items: focus management via arrow keys requires roving tabindex
Test with Speakable
speakable dropdown.html -f text -s all --selector "[role='menu'], [aria-expanded]"
See the menu and menuitem role specifications on the ARIA Roles reference.
Complete reference for all ARIA roles including dialog, menu, tab, and tooltip, with screen reader output examples for each.
Tabs
Tabs organize content into panels where only one panel is visible at a time. The ARIA tabs pattern uses a container with role="tablist" holding elements with role="tab", each controlling a corresponding panel with role="tabpanel". The selected tab is indicated with aria-selected="true", and each tab uses aria-controls to reference its panel. Arrow keys navigate between tabs, while the Tab key moves focus from the active tab into the panel content. This separation of navigation (arrows for tabs) and content access (Tab key for panel) is fundamental to usability.
<div class="tabs">
<div role="tablist" aria-label="Account settings">
<button
role="tab"
aria-selected="true"
aria-controls="panel-general"
id="tab-general"
tabindex="0"
>
General
</button>
<button
role="tab"
aria-selected="false"
aria-controls="panel-settings"
id="tab-settings"
tabindex="-1"
>
Settings
</button>
<button
role="tab"
aria-selected="false"
aria-controls="panel-billing"
id="tab-billing"
tabindex="-1"
>
Billing
</button>
<button
role="tab"
aria-selected="false"
aria-controls="panel-notifications"
id="tab-notifications"
tabindex="-1"
>
Notifications
</button>
</div>
<div
role="tabpanel"
id="panel-general"
aria-labelledby="tab-general"
tabindex="0"
>
<p>General account settings content goes here.</p>
</div>
<!-- Other panels hidden when not selected -->
<div
role="tabpanel"
id="panel-settings"
aria-labelledby="tab-settings"
tabindex="0"
hidden
>
<p>Settings content goes here.</p>
</div>
</div>Keyboard Interaction
- → moves focus to the next tab (wraps from last to first)
- ← moves focus to the previous tab (wraps from first to last)
- Tab moves focus from the active tab into the associated panel content
- Home moves focus to the first tab
- End moves focus to the last tab
- Activation can be automatic (on focus) or manual (on Enter/Space) depending on complexity
Screen Reader Output
When the "Settings" tab receives focus:
When focus moves to the tab panel:
Common Mistakes
- All tab panels remain visible to screen readers: only the active panel should be in the accessibility tree
- No arrow key support: users expect to navigate tabs with arrow keys, not the Tab key
- Missing
aria-selected: screen readers cannot indicate which tab is active - Using
tabindex="0"on all tabs instead of roving tabindex: creates confusing Tab key behavior - Missing
aria-controlsrelationship: screen readers cannot associate tabs with their panels - No
tabindex="0"on panels: keyboard users cannot Tab into the panel content
Test with Speakable
speakable tabs.html -f text -s all --selector "[role='tablist'], [role='tabpanel']"
See the tab, tablist, and tabpanel role specifications on the ARIA Roles reference.
Accordion
Accordions vertically stack sections of content with toggleable visibility. Each section has a header that serves as a trigger button, expanding or collapsing the associated content region. The trigger button uses aria-expanded to communicate its state, and the controlled content can use role="region" with an aria-labelledby referencing the header for context. This pattern is common in FAQ sections, settings pages, and anywhere progressive disclosure improves scannability. The key requirement is that the trigger must be a proper button element so keyboard and screen reader users can activate it reliably.
<div class="accordion">
<h3>
<button
aria-expanded="true"
aria-controls="section1-content"
id="section1-header"
>
What is your return policy?
</button>
</h3>
<div
id="section1-content"
role="region"
aria-labelledby="section1-header"
>
<p>You can return any item within 30 days of purchase
for a full refund.</p>
</div>
<h3>
<button
aria-expanded="false"
aria-controls="section2-content"
id="section2-header"
>
How long does shipping take?
</button>
</h3>
<div
id="section2-content"
role="region"
aria-labelledby="section2-header"
hidden
>
<p>Standard shipping takes 5-7 business days.</p>
</div>
<h3>
<button
aria-expanded="false"
aria-controls="section3-content"
id="section3-header"
>
Do you offer international shipping?
</button>
</h3>
<div
id="section3-content"
role="region"
aria-labelledby="section3-header"
hidden
>
<p>Yes, we ship to over 50 countries worldwide.</p>
</div>
</div>Keyboard Interaction
- Enter or Space on a header button toggles the section open or closed
- ↓ moves focus to the next accordion header (optional enhancement)
- ↑ moves focus to the previous accordion header (optional enhancement)
- Home moves focus to the first accordion header (optional enhancement)
- End moves focus to the last accordion header (optional enhancement)
Screen Reader Output
When focused on an expanded header:
When focused on a collapsed header:
Common Mistakes
- Using
<div>or<span>instead of<button>for the toggle: non-button elements are not keyboard accessible by default - Missing
aria-expanded: screen readers cannot communicate whether a section is open or closed - Hiding content with CSS (
opacity: 0orheight: 0) instead of thehiddenattribute: content remains in the accessibility tree - No heading element wrapping the button: accordion headers lose their structural role in the page outline
- Toggling
aria-expandedon the panel instead of the button: the expanded state belongs on the element the user interacts with
Test with Speakable
speakable accordion.html -f text -s nvda --selector "button[aria-expanded]"
Learn more about the region role and aria-expanded property on the ARIA Roles reference.
Tooltip
Tooltips provide supplementary descriptions for interface elements, appearing when a user hovers over or focuses on a trigger element. The accessible pattern uses aria-describedby on the trigger element pointing to the tooltip content, which carries role="tooltip". This relationship ensures screen readers announce the tooltip text as a description when the trigger receives focus, without requiring the user to visually discover a hover-only popup. The tooltip must appear on keyboard focus (not just mouse hover) and be dismissable with Escape to meet accessibility requirements. Note that tooltips should carry non-essential supplementary information: critical labels belong directly on the element.
<div class="tooltip-container">
<button aria-describedby="save-tooltip">
Save
</button>
<div
id="save-tooltip"
role="tooltip"
class="tooltip"
>
Save your changes to the server (Ctrl+S)
</div>
</div>
<!-- Icon button example with both label and description -->
<div class="tooltip-container">
<button
aria-label="Settings"
aria-describedby="settings-tooltip"
>
<svg aria-hidden="true"><!-- gear icon --></svg>
</button>
<div
id="settings-tooltip"
role="tooltip"
class="tooltip"
>
Open application settings and preferences
</div>
</div>Keyboard Interaction
- Tooltip appears when the trigger element receives keyboard focus
- Tooltip appears when the pointer hovers over the trigger element
- Escape dismisses the tooltip without moving focus
- Tooltip disappears when the trigger loses focus or the pointer leaves the trigger
- Tooltip remains visible while the pointer is over the tooltip itself (allows reading long text)
Screen Reader Output
When the "Save" button receives focus:
Common Mistakes
- Only showing the tooltip on hover: keyboard-only users never see or hear the information
- Using
aria-labelinstead ofaria-describedby: aria-label replaces the accessible name instead of supplementing it - Not using
role="tooltip": screen readers may not associate the text as a description - Tooltip cannot be dismissed with Escape: violates WCAG 1.4.13 Content on Hover or Focus
- Placing essential information only in a tooltip: users who cannot trigger hover (touch devices) miss the content entirely
- Tooltip disappears too quickly: users with motor impairments cannot keep the pointer steady on the trigger
Test with Speakable
speakable tooltip.html -f text -s all --selector "[aria-describedby]"
See the tooltip role details on the ARIA Roles reference.
Combobox / Autocomplete
A combobox combines a text input with a popup list of options, allowing users to either type a value or select from suggestions. The input carries role="combobox" along with aria-expanded to indicate whether the suggestion list is visible, and aria-controls pointing to a listbox. The listbox contains options with role="option", and the currently highlighted option is referenced by aria-activedescendant on the input. This pattern is one of the most complex in ARIA and requires careful state management to keep the screen reader synchronized as the user types, filters options, and makes selections.
<label for="country-input" id="country-label">Country</label>
<div class="combobox-container">
<input
id="country-input"
type="text"
role="combobox"
aria-expanded="true"
aria-controls="country-listbox"
aria-autocomplete="list"
aria-activedescendant="option-2"
aria-labelledby="country-label"
/>
<ul
id="country-listbox"
role="listbox"
aria-label="Countries"
>
<li id="option-1" role="option" aria-selected="false">
Canada
</li>
<li id="option-2" role="option" aria-selected="true">
Germany
</li>
<li id="option-3" role="option" aria-selected="false">
Ghana
</li>
</ul>
</div>
<!-- Closed state -->
<input
type="text"
role="combobox"
aria-expanded="false"
aria-controls="country-listbox"
aria-autocomplete="list"
aria-labelledby="country-label"
/>Keyboard Interaction
- Typing characters filters the listbox options and opens the popup if closed
- ↓ moves the highlight to the next option in the listbox (opens popup if closed)
- ↑ moves the highlight to the previous option in the listbox
- Enter selects the highlighted option, closes the popup, and places the value in the input
- Escape closes the popup without selecting, clearing the highlight
- Home / End move the text cursor within the input (standard text editing)
Screen Reader Output
When the combobox input receives focus (popup closed):
When expanded with an option highlighted:
Common Mistakes
- Not announcing filtered result count: screen reader users have no idea how many options remain after typing
- Missing
role="combobox"on the input: screen readers treat it as a plain text field - Not updating
aria-activedescendantas the highlight moves: screen readers do not announce the current option - Using
aria-expandedwithout a value: must toggle between "true" and "false" explicitly - Missing
aria-autocomplete: screen readers cannot communicate that typing filters the list - Moving DOM focus into the listbox instead of using
aria-activedescendant: this removes the cursor from the input and disrupts typing
Test with Speakable
speakable combobox.html -f text -s all --selector "[role='combobox']"
See the combobox and listbox role specifications on the ARIA Roles reference.
Verification Workflow
Each pattern above includes a Speakable CLI command for quick verification. Here is a recommended workflow for integrating pattern checks into your development process:
- Build the component: implement the HTML structure following the patterns above, including all required ARIA attributes and keyboard handlers.
- Run Speakable: use the provided CLI command to verify the screen reader output matches the expected announcements for all four readers.
- Check keyboard flow: manually test the keyboard interaction list. Ensure focus moves as documented and all required key bindings function correctly.
- Fix discrepancies: if the Speakable output does not match expectations, review the common mistakes section for the relevant pattern to identify likely issues.
- Add to CI: integrate Speakable checks into your CI/CD pipeline to catch accessibility regressions automatically. See the CI/CD Integration guide for details.
- Manual test: automated tools cannot catch every nuance. Test with a real screen reader (NVDA on Windows is free) to validate the experience end-to-end, especially for complex interactions like focus restoration and live region updates.
Remember that Speakable predicts screen reader output based on ARIA semantics and HTML structure. It catches the vast majority of structural issues (missing roles, broken label relationships, incorrect expanded states) but cannot validate timing-dependent behaviors like animation sequences or live region update order. Pair automated prediction with periodic manual testing for comprehensive coverage.
Related Pages
ARIA Roles
Complete reference for all ARIA roles with screen reader output examples and usage guidelines.
Common Mistakes
Frequently seen accessibility errors and how to fix them with Speakable verification.
Keyboard Navigation
Patterns and best practices for building fully keyboard-accessible interfaces.
Focus Management
Strategies for managing focus in dynamic interfaces including modals, routers, and live updates.