A11y Holy Grail

// ARIA + Semantic HTML cheat sheet — quick reference for agents & developers

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 tab sequence 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. you@example.com</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;
}

01 — Document structure

Canonical page skeleton structure
<html lang="en">
<head>
  <meta charset="UTF-8"/>
  <meta name="viewport"
        content="width=device-width,
                 initial-scale=1"/>
  <title>Page — Site</title>
</head>
<body>
  <a href="#main" class="sr-only-focusable">
    Skip to content
  </a>
  <header>...</header>
  <nav aria-label="Main">...</nav>
  <main id="main">...</main>
  <footer>...</footer>
</body>
</html>
Article with metadata content
<article>
  <header>
    <h1>Article title</h1>
    <p>By
      <address>
        <a rel="author"
           href="/authors/jane">
          Jane Smith
        </a>
      </address>
    </p>
    <time datetime="2025-03-01">
      March 2025
    </time>
  </header>
  <p>Content...</p>
  <footer>Tags, related...</footer>
</article>

02 — Element reference

ElementUse forNotes / common mistakes
<main>Primary page contentOne per page; skip-link target
<header>Page or section headerImplicit banner role only at top level
<footer>Page or section footerImplicit contentinfo only at top level
<nav>Navigation landmarksLabel with aria-label if more than one
<aside>Tangentially related contentSidebars, pull quotes, callouts
<article>Self-contained contentPosts, cards, comments — can nest
<section>Thematic grouping with a headingNeeds aria-labelledby for landmark role; don't use as div
<h1>–<h6>Heading hierarchyOne h1 per page; don't skip levels
<p>Paragraphs of textDon't use for spacing — use CSS
<ul> / <ol>Unordered / ordered listAlways use li as direct children
<dl>Description list (term–value pairs)Good for metadata, glossaries, key-value UI
<figure> + <figcaption>Image with captionfigcaption labels the figure for AT
<picture>Art-directed responsive imagesAlways include an <img> fallback with alt
<time>Dates and timesdatetime attr must be machine-readable ISO 8601
<address>Contact info for nearest <article> or <body>Not for arbitrary postal addresses in content
<blockquote>Extended quotationcite attr for source URL; use <cite> inside for attribution
<cite>Title of a creative workNot for person names
<abbr>Abbreviationtitle attr provides expansion
<mark>Highlighted / search matchConveys relevance, not emphasis
<strong>Strong importanceNot just bold — conveys semantic weight
<em>Stress emphasisChanges sentence meaning; not just italic
<small>Side comments, fine printNot for decorative small text
<details> + <summary>Native disclosure widgetFree keyboard + AT support; no JS needed
<dialog>Modal or non-modal dialogshowModal() traps focus natively in modern browsers
<search>Search landmark wrapperHTML 2023 — replaces role="search" on <form>
<output>Result of a calculation or actionImplicit aria-live="polite"
<meter>Scalar value in a known rangeDisk usage, rating — NOT for progress
<progress>Completion progressIndeterminate when no value attr
<datalist>Autocomplete suggestions for inputLink via input list="id"
<fieldset> + <legend>Group related form fieldslegend is the group label for AT

03 — Heading hierarchy

Correct do
<h1>Site / Page title</h1>
  <h2>Major section</h2>
    <h3>Subsection</h3>
    <h3>Subsection</h3>
  <h2>Another section</h2>
    <h3>Subsection</h3>
      <h4>Deep subsection</h4>

/* One h1 per page          */
/* Never skip a level (h2→h4) */
/* Level = structure, not size */
Wrong don't
<h1>Title</h1>
<h1>Another h1</h1>
  <h4>Skipped h2 + h3</h4>

/* Using h3 because it "looks  */
/* right" in CSS — wrong.      */
/* Style with CSS, not heading */
/* level.                      */

<p><b>Fake heading</b></p>
/* AT can't detect this        */

04 — Images

Image typeMarkup patternalt rule
Informative<img src alt="description">Describe what the image conveys, not what it looks like
Decorative<img src alt="">Empty alt — AT skips it entirely
Functional (button icon)<img src alt="Search">Describe the action, not the icon ("Magnifying glass" is wrong)
Complex (chart, diagram)<figure><img><figcaption>Short alt + full description in figcaption or aria-details
SVG inline<svg role="img" aria-label="...">Add title + desc inside SVG; aria-labelledby both ids
SVG decorative<svg aria-hidden="true">Hide from AT completely
CSS background imageNo img elementIf meaningful, provide text alternative in DOM

05 — Forms

Full form anatomy pattern
<form aria-labelledby="form-title">
  <h2 id="form-title">Sign up</h2>

  <fieldset>
    <legend>Personal details</legend>
    <label for="name">Full name</label>
    <input id="name"
           type="text"
           autocomplete="name"
           required/>
  </fieldset>

  <button type="submit">Create account</button>
</form>
Input types reference inputs
type="email"autocomplete="email"
type="tel"autocomplete="tel"
type="url"autocomplete="url"
type="search"role="searchbox" implicit
type="number"inputmode="numeric"
Use inputmode for numeric patterns (e.g. OTP, card)
type="date"min / max / step
type="range"+ aria-valuetext label
type="file"accept=".pdf,.jpg"
type="password"autocomplete="current-password"
type="hidden"never receives focus

06 — Tables

Data table pattern
<table>
  <caption>Q3 Sales by Region</caption>
  <thead>
    <tr>
      <th scope="col">Region</th>
      <th scope="col">Revenue</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">APAC</th>
      <td>$1.2M</td>
    </tr>
  </tbody>
</table>
Table rules rules
Always use <caption> — it's the table's accessible name
Use scope="col" on column headers, scope="row" on row headers
Complex tables: use id + headers attributes instead of scope
Never use tables for layout — use CSS Grid / Flexbox
<thead>, <tbody>, <tfoot> are structural — always include them
Empty cells need <td>&nbsp;</td> or a meaningful aria-label

07 — Links vs buttons

Use caseElementRule
Navigate to a URL<a href="...">Always has an href; right-click menu works; opens in new tab
Trigger an action (no navigation)<button type="button">Activated by Enter + Space; no href
Submit a form<button type="submit">Default type inside <form> — always be explicit
Reset a form<button type="reset">Rarely needed; usually a bad UX
Download a file<a href download>download attr triggers save dialog; aria-label the filename
External link<a href target="_blank" rel="noopener noreferrer">Warn users: add "(opens in new tab)" in label
Anchor / jump link<a href="#section-id">Target needs a matching id; move focus to target
The cardinal rule: if it goes somewhere → <a>. If it does something → <button>. Never a <div> or <span> for either.

08 — Inline semantics quick ref

Text-level elements inline
<strong>strong importance
<em>stress emphasis
<b>stylistic bold, no importance
<i>idiomatic / technical term
<u>unarticulated annotation
<s>no longer accurate
<mark>highlighted / search result
<code>inline code fragment
<kbd>keyboard input
<samp>sample program output
<var>variable in math/code
<sub> / <sup>subscript / superscript
<wbr>optional line break opportunity
<bdi>bidirectional isolate
Deprecated — avoid deprecated
<font> → use CSS font-* properties
<center> → use CSS text-align: center
<strike> → use <s> or CSS text-decoration
<b> for importance → use <strong>
<i> for emphasis → use <em>
<acronym> → use <abbr>
<table> for layout → use CSS Grid/Flex
<br> for spacing → use CSS margin/padding
<hr> purely visual → use CSS border on a div

09 — Native interactive elements

<details> + <summary> — free accordion native
/* No JS, no ARIA needed */
<details>
  <summary>
    Section title
  </summary>
  <p>Hidden content...</p>
</details>

/* Add open attr to start expanded */
<details open>...
<dialog> — native modal native
<dialog id="dlg"
        aria-labelledby="dlg-h">
  <h2 id="dlg-h">Title</h2>
  <p>Content</p>
  <button autofocus>Close</button>
</dialog>

// JS: showModal() traps focus
// Esc closes natively
// ::backdrop for overlay