Accessible Component Patterns
Component Patterns
Button
- Use
<button>, never <div onclick> or <a> for actions
- Toggle buttons:
aria-pressed="true|false"
- Icon-only buttons:
aria-label="descriptive text"
- Loading state:
aria-disabled="true" + aria-busy="true", keep label readable
- Keyboard:
Enter and Space activate
Link vs Button
- Link: navigates somewhere (
<a href>)
- Button: does something (
<button>)
- Never use
<a> without href. Never use <a href="#" onclick> for actions.
Modal Dialog
role="dialog" + aria-modal="true" + aria-labelledby="title-id"
- Focus trap: Tab cycles within modal, never escapes to background
- On open: move focus to first focusable element (or the close button)
- On close: return focus to the element that triggered the modal
Escape closes the modal
- Background content:
aria-hidden="true" on content behind modal, or inert attribute
Dropdown Menu
- Trigger:
aria-haspopup="true" + aria-expanded="true|false"
- Menu:
role="menu", items: role="menuitem"
- Keyboard:
Enter/Space opens, ArrowDown/ArrowUp navigates, Escape closes
- On open: focus moves to first menu item
- On close: focus returns to trigger
Tabs
- Container:
role="tablist"
- Tab:
role="tab" + aria-selected="true|false" + aria-controls="panel-id"
- Panel:
role="tabpanel" + aria-labelledby="tab-id"
- Keyboard:
ArrowLeft/ArrowRight between tabs, Tab moves into panel content
- Active tab:
tabindex="0", inactive tabs: tabindex="-1"
Combobox (Autocomplete)
- Input:
role="combobox" + aria-expanded + aria-controls="listbox-id" + aria-autocomplete="list|both"
- Options:
role="listbox" > role="option"
- Active option:
aria-activedescendant="option-id" on the input
- Keyboard:
ArrowDown/ArrowUp navigate options, Enter selects, Escape closes list
- Announce result count: live region with "X results available"
Toast / Notification
- Use
role="status" (polite) for info, role="alert" (assertive) for errors
- Add
aria-live="polite" or aria-live="assertive"
- Don't auto-dismiss error toasts — user may need time to read
- Info toasts: auto-dismiss is OK (5+ seconds), provide pause on hover
- Don't stack too many — screen readers announce each one
Form
- Every input needs a
<label> with for attribute (or wrapping)
- Error messages:
aria-describedby pointing to error text + aria-invalid="true"
- Required fields:
aria-required="true" (or HTML required)
- Group related fields:
<fieldset> + <legend>
- Submit feedback: announce success/failure via live region, not just visual change
Accordion
- Trigger:
<button> with aria-expanded="true|false" + aria-controls="panel-id"
- Panel: region with
role="region" + aria-labelledby="trigger-id"
- Keyboard:
Enter/Space toggles, standard focus order (no arrow key nav needed)
Focus Management Patterns
Focus Trap
Contain focus within a region (modals, drawers):
- Track first and last focusable elements
- On
Tab from last: move to first
- On
Shift+Tab from first: move to last
- Use
inert attribute on background content when available
Focus Restoration
When closing an overlay, return focus to the trigger:
- Store
document.activeElement before opening
- Restore on close
- If trigger no longer exists, focus nearest logical ancestor
Skip Link
First focusable element on the page, hidden until focused:
- "Skip to main content" linking to
<main> or #content
- Visible on
:focus, hidden otherwise
- Must be the first item in tab order
Roving Tabindex
For widget-internal navigation (tabs, toolbars, menus):
- Active item:
tabindex="0"
- All other items:
tabindex="-1"
- Arrow keys move focus and update tabindex values
Tab exits the widget entirely
Screen Reader Patterns
Live Regions
aria-live="polite": announced after current speech finishes (status updates, search results count)
aria-live="assertive": interrupts current speech (errors, urgent alerts)
- Add the live region to DOM first, then update its content — screen readers only announce changes
Visually Hidden Content
For screen-reader-only text:
.sr-only {
position: absolute;
width: 1px; height: 1px;
padding: 0; margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
Never use display: none or visibility: hidden for SR-only content — those hide from screen readers too.
Dynamic Content
- Loading states:
aria-busy="true" on the container, announce "Loading" via live region
- Infinite scroll: announce new content count, maintain focus position
- Single-page navigation: announce new page title via live region, move focus to
<h1> or <main>
Testing Checklist
For every interactive component:
Converted and distributed by TomeVault — claim your Tome and manage your conversions.
1---2name: hakal-team-skills-accessible-component-patterns3description: Accessible Component Patterns4---56# Accessible Component Patterns78## Component Patterns910### Button11- Use `<button>`, never `<div onclick>` or `<a>` for actions12- Toggle buttons: `aria-pressed="true|false"`13- Icon-only buttons: `aria-label="descriptive text"`14- Loading state: `aria-disabled="true"` + `aria-busy="true"`, keep label readable15- Keyboard: `Enter` and `Space` activate1617### Link vs Button18- **Link**: navigates somewhere (`<a href>`)19- **Button**: does something (`<button>`)20- Never use `<a>` without `href`. Never use `<a href="#" onclick>` for actions.2122### Modal Dialog23- `role="dialog"` + `aria-modal="true"` + `aria-labelledby="title-id"`24- **Focus trap**: Tab cycles within modal, never escapes to background25- On open: move focus to first focusable element (or the close button)26- On close: return focus to the element that triggered the modal27- `Escape` closes the modal28- Background content: `aria-hidden="true"` on content behind modal, or `inert` attribute2930### Dropdown Menu31- Trigger: `aria-haspopup="true"` + `aria-expanded="true|false"`32- Menu: `role="menu"`, items: `role="menuitem"`33- Keyboard: `Enter`/`Space` opens, `ArrowDown`/`ArrowUp` navigates, `Escape` closes34- On open: focus moves to first menu item35- On close: focus returns to trigger3637### Tabs38- Container: `role="tablist"`39- Tab: `role="tab"` + `aria-selected="true|false"` + `aria-controls="panel-id"`40- Panel: `role="tabpanel"` + `aria-labelledby="tab-id"`41- Keyboard: `ArrowLeft`/`ArrowRight` between tabs, `Tab` moves into panel content42- Active tab: `tabindex="0"`, inactive tabs: `tabindex="-1"`4344### Combobox (Autocomplete)45- Input: `role="combobox"` + `aria-expanded` + `aria-controls="listbox-id"` + `aria-autocomplete="list|both"`46- Options: `role="listbox"` > `role="option"`47- Active option: `aria-activedescendant="option-id"` on the input48- Keyboard: `ArrowDown`/`ArrowUp` navigate options, `Enter` selects, `Escape` closes list49- Announce result count: live region with "X results available"5051### Toast / Notification52- Use `role="status"` (polite) for info, `role="alert"` (assertive) for errors53- Add `aria-live="polite"` or `aria-live="assertive"`54- Don't auto-dismiss error toasts — user may need time to read55- Info toasts: auto-dismiss is OK (5+ seconds), provide pause on hover56- Don't stack too many — screen readers announce each one5758### Form59- Every input needs a `<label>` with `for` attribute (or wrapping)60- Error messages: `aria-describedby` pointing to error text + `aria-invalid="true"`61- Required fields: `aria-required="true"` (or HTML `required`)62- Group related fields: `<fieldset>` + `<legend>`63- Submit feedback: announce success/failure via live region, not just visual change6465### Accordion66- Trigger: `<button>` with `aria-expanded="true|false"` + `aria-controls="panel-id"`67- Panel: region with `role="region"` + `aria-labelledby="trigger-id"`68- Keyboard: `Enter`/`Space` toggles, standard focus order (no arrow key nav needed)6970## Focus Management Patterns7172### Focus Trap73Contain focus within a region (modals, drawers):74- Track first and last focusable elements75- On `Tab` from last: move to first76- On `Shift+Tab` from first: move to last77- Use `inert` attribute on background content when available7879### Focus Restoration80When closing an overlay, return focus to the trigger:81- Store `document.activeElement` before opening82- Restore on close83- If trigger no longer exists, focus nearest logical ancestor8485### Skip Link86First focusable element on the page, hidden until focused:87- "Skip to main content" linking to `<main>` or `#content`88- Visible on `:focus`, hidden otherwise89- Must be the first item in tab order9091### Roving Tabindex92For widget-internal navigation (tabs, toolbars, menus):93- Active item: `tabindex="0"`94- All other items: `tabindex="-1"`95- Arrow keys move focus and update tabindex values96- `Tab` exits the widget entirely9798## Screen Reader Patterns99100### Live Regions101- `aria-live="polite"`: announced after current speech finishes (status updates, search results count)102- `aria-live="assertive"`: interrupts current speech (errors, urgent alerts)103- Add the live region to DOM first, then update its content — screen readers only announce *changes*104105### Visually Hidden Content106For screen-reader-only text:107```css108.sr-only {109 position: absolute;110 width: 1px; height: 1px;111 padding: 0; margin: -1px;112 overflow: hidden;113 clip: rect(0, 0, 0, 0);114 white-space: nowrap;115 border: 0;116}117```118Never use `display: none` or `visibility: hidden` for SR-only content — those hide from screen readers too.119120### Dynamic Content121- Loading states: `aria-busy="true"` on the container, announce "Loading" via live region122- Infinite scroll: announce new content count, maintain focus position123- Single-page navigation: announce new page title via live region, move focus to `<h1>` or `<main>`124125## Testing Checklist126127For every interactive component:128- [ ] Keyboard-only operation (no mouse required)129- [ ] Visible focus indicator on every focusable element130- [ ] Screen reader announces purpose, state, and changes131- [ ] Color contrast 4.5:1 for text, 3:1 for UI elements132- [ ] Touch target minimum 44x44px on mobile133- [ ] No content conveyed by color alone134- [ ] Works with 200% zoom without horizontal scroll135136---137> Converted and distributed by [TomeVault](https://tomevault.io/claim/hakal) — claim your Tome and manage your conversions.138<!-- tomevault:4.0:skill_md:2026-04-11 -->