// a11y cheat sheet for agents & developers — quick reference
| UI pattern | Correct role(s) | Notes |
|---|---|---|
| Toggle options (multi) | group + input[checkbox] | Each option is independently on/off |
| Pick one from a list | listbox + option | Like a styled <select>; use aria-selected |
| Pick many from a list | listbox + option + aria-multiselectable | Drop visual checkboxes; use aria-selected only |
| Collapsible section | button[aria-expanded] + region or group | Never role="button" on a div if you can use <button> |
| Modal dialog | dialog + aria-modal="true" | Trap focus; aria-labelledby title |
| Tooltip | tooltip (on popup) + aria-describedby (on trigger) | Not for interactive content |
| Tab switcher | tablist + tab + tabpanel | Arrow keys between tabs; Tab into panel |
| Accordion | button[aria-expanded] per item | No special role on wrapper needed |
| Alert / toast | alert or status | alert = assertive; status = polite |
| Navigation menu | nav + ul/li or menubar + menuitem | menubar only for app-style menus, not site nav |
| Combobox / autocomplete | combobox + listbox + option | aria-expanded, aria-controls, aria-activedescendant |
| Breadcrumbs | nav[aria-label="Breadcrumb"] + ol + aria-current="page" | Last item gets aria-current |
| Progress bar | progressbar + aria-valuenow/min/max | Add aria-valuetext for human label |
| Slider | slider + aria-valuenow/min/max/valuetext | Arrow keys move value; Home/End jump to limits |
| Switch (toggle) | switch + aria-checked | NOT checkbox — implies on/off system setting |
| Tree / tree item | tree + treeitem + group | aria-expanded on parent nodes; arrow keys navigate |
| Data table | table + columnheader/rowheader + gridcell | Use scope="col/row" on th elements |
| Sortable table | columnheader + aria-sort | Values: ascending, descending, none, other |
| Search input | search (landmark) + input[type=search] | Wrap in <search> or role="search" |
| HTML element | Implicit role | aria-label needed? | Rule |
|---|---|---|---|
| <header> | banner | No (1 per page) | Top-level only; inside <article> it has no role |
| <nav> | navigation | Yes if multiple | Label distinguishes "Main nav" vs "Breadcrumb" |
| <main> | main | No | Only one per page |
| <aside> | complementary | Yes if multiple | — |
| <footer> | contentinfo | No (1 per page) | Top-level only |
| <search> / role="search" | search | Optional | New HTML element; wraps search form |
| <section> | region (if labelled) | Required for role | Without label it has no landmark role |
| <form> | form (if labelled) | Required for role | Without label it has no landmark role |
| <article> | article | Optional | Self-contained; can nest |
<div role="dialog" aria-modal="true" aria-labelledby="dlg-title" tabindex="-1"> <h2 id="dlg-title">Title</h2> /* Move focus here on open */ /* Trap Tab/Shift+Tab inside */ /* Restore focus on close */ </div>
/* Only one item in tabsequence at a time */ <div role="toolbar"> <button tabindex="0">Active</button> <button tabindex="-1">Inactive</button> <button tabindex="-1">Inactive</button> </div> /* Arrow keys move tabindex="0" */ /* Tab exits the whole widget */
| Widget | Key | Expected behaviour |
|---|---|---|
| Button | Enter Space | Activate |
| Checkbox | Space | Toggle checked |
| Radio group | ↑ ↓ | Move selection; Tab exits group |
| Select / Listbox | ↑ ↓ Home End | Navigate; Space/Enter select |
| Tab list | ↑↓ or ←→ | Move between tabs; Tab enters panel |
| Dialog | Esc | Close and return focus to trigger |
| Combobox | ↓ opens · Esc closes · Enter selects | Type filters list |
| Tree | → expand · ← collapse · ↑↓ navigate | Home/End to first/last node |
| Slider | ←→ or ↑↓ | ±1 step · Home/End min/max · Page±10% |
| Menu / Menubar | ←→ top items · ↑↓ sub items | Esc closes; Tab exits menubar |
<div> <button aria-expanded="false" aria-controls="panel-1" id="btn-1"> Section title </button> <div id="panel-1" role="region" aria-labelledby="btn-1" hidden> Content... </div> </div>
<div role="combobox" aria-expanded="true" aria-haspopup="listbox" aria-owns="opts"> <input type="text" aria-autocomplete="list" aria-activedescendant="opt2"/> </div> <ul role="listbox" id="opts"> <li role="option" id="opt1">Apple</li> <li role="option" id="opt2" aria-selected="true">Banana</li> </ul>
<div role="tablist" aria-label="Settings"> <button role="tab" aria-selected="true" aria-controls="panel-a" tabindex="0">General</button> <button role="tab" aria-selected="false" aria-controls="panel-b" tabindex="-1">Security</button> </div> <div role="tabpanel" id="panel-a" aria-labelledby="tab-a" tabindex="0">...</div>
<div> <label for="email">Email</label> <input id="email" type="email" aria-describedby="email-hint email-err" aria-invalid="true" aria-required="true"/> <span id="email-hint">e.g. [email protected]</span> <span id="email-err" role="alert">Invalid email</span> </div>
<button>, <input>, <select> before reaching for role=. Native elements give you keyboard, focus, and AT support for free.<div> is invisible to AT. A <div role="button"> without tabindex/keyboard is broken. Broken ARIA is worse than no ARIA.aria-label. Groups need aria-labelledby.outline: none without a custom focus style. Every interactive element must have a visible focus indicator.aria-hidden="true" removes from AT tree only. Element is still visible and focusable. Never put it on a focusable element.aria-live regions in the DOM on page load. Inserting them dynamically before updating means AT misses the first announcement.display:none or the hidden attribute to truly hide collapsed content. visibility:hidden / opacity:0 may not be enough./* Visually hidden but announced by screen readers */ .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; } /* Becomes visible on focus — for skip links */ .sr-only-focusable:focus { position: static; width: auto; height: auto; overflow: visible; clip: auto; white-space: normal; }