ARIA Holy Grail

// a11y cheat sheet for agents & developers — quick reference

01 — Pick the right role

UI patternCorrect role(s)Notes
Toggle options (multi)group + input[checkbox]Each option is independently on/off
Pick one from a listlistbox + optionLike a styled <select>; use aria-selected
Pick many from a listlistbox + option + aria-multiselectableDrop visual checkboxes; use aria-selected only
Collapsible sectionbutton[aria-expanded] + region or groupNever role="button" on a div if you can use <button>
Modal dialogdialog + aria-modal="true"Trap focus; aria-labelledby title
Tooltiptooltip (on popup) + aria-describedby (on trigger)Not for interactive content
Tab switchertablist + tab + tabpanelArrow keys between tabs; Tab into panel
Accordionbutton[aria-expanded] per itemNo special role on wrapper needed
Alert / toastalert or statusalert = assertive; status = polite
Navigation menunav + ul/li or menubar + menuitemmenubar only for app-style menus, not site nav
Combobox / autocompletecombobox + listbox + optionaria-expanded, aria-controls, aria-activedescendant
Breadcrumbsnav[aria-label="Breadcrumb"] + ol + aria-current="page"Last item gets aria-current
Progress barprogressbar + aria-valuenow/min/maxAdd aria-valuetext for human label
Sliderslider + aria-valuenow/min/max/valuetextArrow keys move value; Home/End jump to limits
Switch (toggle)switch + aria-checkedNOT checkbox — implies on/off system setting
Tree / tree itemtree + treeitem + grouparia-expanded on parent nodes; arrow keys navigate
Data tabletable + columnheader/rowheader + gridcellUse scope="col/row" on th elements
Sortable tablecolumnheader + aria-sortValues: ascending, descending, none, other
Search inputsearch (landmark) + input[type=search]Wrap in <search> or role="search"

02 — Essential attributes

Labelling naming
aria-label"Close dialog"
Inline string label — use when no visible text exists
aria-labelledby"id1 id2"
Points to visible element(s) — preferred over aria-label
aria-describedby"hint-id"
Secondary description — announced after label
aria-details"longdesc-id"
Links to complex description (figure, table, etc.)
State state
aria-expandedtrue | false
aria-checkedtrue | false | mixed
aria-selectedtrue | false
aria-pressedtrue | false | mixed
aria-disabledtrue | false
Prefer over HTML disabled — keeps element focusable
aria-hiddentrue
Removes from AT tree — never on focusable elements
aria-invalidtrue | grammar | spelling
aria-busytrue | false
Relationships relations
aria-controls"panel-id"
Trigger → controlled region (disclosure, tabs)
aria-owns"child-id"
Reparents DOM nodes not in subtree — use sparingly
aria-activedescendant"option-id"
Composite widget focus — focus stays on container
aria-flowto"next-id"
Alternative reading order (rare)
aria-posinset3
aria-setsize10
Item 3 of 10 — for virtualised / lazy lists
Live regions live
aria-livepolite | assertive | off
polite = waits for idle; assertive = interrupts immediately
aria-atomictrue | false
true = re-read whole region; false = only changed node
aria-relevantadditions | removals | text | all
Default is "additions text" — rarely needs changing
role="status"≡ live=polite atomic=true
role="alert"≡ live=assertive atomic=true
Pre-inject live regions into DOM before updating them

03 — Landmark regions

HTML elementImplicit rolearia-label needed?Rule
<header>bannerNo (1 per page)Top-level only; inside <article> it has no role
<nav>navigationYes if multipleLabel distinguishes "Main nav" vs "Breadcrumb"
<main>mainNoOnly one per page
<aside>complementaryYes if multiple
<footer>contentinfoNo (1 per page)Top-level only
<search> / role="search"searchOptionalNew HTML element; wraps search form
<section>region (if labelled)Required for roleWithout label it has no landmark role
<form>form (if labelled)Required for roleWithout label it has no landmark role
<article>articleOptionalSelf-contained; can nest

04 — Focus management rules

Focus trap (modal) modal
<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>
Roving tabindex (composite) pattern
/* 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   */
Rules: tabindex="0" = in natural tab order · tabindex="-1" = focusable by JS only · tabindex > 0 = avoid entirely · Never skip focus on interactive elements · After route change move focus to <h1> or main landmark

05 — Keyboard contract by widget

WidgetKeyExpected behaviour
ButtonEnter SpaceActivate
CheckboxSpaceToggle checked
Radio group Move selection; Tab exits group
Select / Listbox Home EndNavigate; Space/Enter select
Tab list or Move between tabs; Tab enters panel
DialogEscClose and return focus to trigger
Combobox opens · Esc closes · Enter selectsType filters list
Tree expand · collapse · navigateHome/End to first/last node
Slider or ±1 step · Home/End min/max · Page±10%
Menu / Menubar top items · sub itemsEsc closes; Tab exits menubar

06 — Holy grail patterns

Disclosure (accordion item) pattern
<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>
Combobox pattern
<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>
Tab list pattern
<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>
Form field with error pattern
<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>

07 — Golden rules

Native first
Use <button>, <input>, <select> before reaching for role=. Native elements give you keyboard, focus, and AT support for free.
No ARIA > Bad ARIA
A plain <div> is invisible to AT. A <div role="button"> without tabindex/keyboard is broken. Broken ARIA is worse than no ARIA.
Label everything interactive
Every button, input, and landmark needs an accessible name. Icon buttons need aria-label. Groups need aria-labelledby.
Don't suppress focus
Never outline: none without a custom focus style. Every interactive element must have a visible focus indicator.
aria-hidden ≠ display:none
aria-hidden="true" removes from AT tree only. Element is still visible and focusable. Never put it on a focusable element.
State must be programmatic
Color alone is not state. Selected, expanded, invalid — each must be expressed in ARIA or HTML, not only visually.
Inject live regions early
Put aria-live regions in the DOM on page load. Inserting them dynamically before updating means AT misses the first announcement.
display:none hides from AT
Use display:none or the hidden attribute to truly hide collapsed content. visibility:hidden / opacity:0 may not be enough.

08 — Visually hidden utility

/* 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;
}