Accessibility
01 / 02

Semantic HTML, ARIA & Landmarks

Semantic HTML, ARIA & Landmarks

Start with native elements. Reach for ARIA only when the platform does not give you the role you need.

The First Rule of ARIA

No ARIA is better than bad ARIA. If a native HTML element does the job, use it. ARIA is a patch layer for components without a native equivalent (combobox, tablist, dialog), not a replacement for div soup.

<!-- BAD — div pretending to be a button -->
<div onclick="submit()" class="btn">Save</div>
<!-- Not focusable, no keyboard, no role announced -->

<!-- GOOD — real button -->
<button type="button" onclick="submit()">Save</button>
<!-- Free: focus, Enter/Space activation, role, disabled state -->

<!-- BAD — link as button -->
<a href="#" onclick="openModal()">Open</a>
<!-- Should be button — there's no navigation -->

<!-- BAD — list of divs -->
<div class="nav">
  <div>Home</div><div>About</div>
</div>

<!-- GOOD — semantic list inside a landmark -->
<nav aria-label="Main">
  <ul>
    <li><a href="/">Home</a></li>
    <li><a href="/about">About</a></li>
  </ul>
</nav>

Landmarks & Document Structure

<!-- Screen reader users jump between landmarks. Use them, exactly one of each (except nav). -->
<header>             <!-- role="banner" -->
  <a href="/">Logo</a>
  <nav aria-label="Primary">...</nav>
</header>

<main>               <!-- role="main" — one per page -->
  <h1>Page title</h1>
  <article>...</article>
</main>

<aside>              <!-- role="complementary" -->
  <h2>Related</h2>
</aside>

<footer>             <!-- role="contentinfo" -->
  <nav aria-label="Legal">...</nav>
</footer>

<!-- Heading hierarchy must not skip levels.
     h1 → h2 → h3, never h1 → h3. Visual size is independent of level. -->

<!-- Multiple <nav> elements? Distinguish with aria-label. -->
<nav aria-label="Breadcrumb">...</nav>
<nav aria-label="Pagination">...</nav>

Forms & Labels

<!-- Every input needs an associated label. Placeholder ≠ label. -->
<label for="email">Email</label>
<input id="email" name="email" type="email" autocomplete="email" required>

<!-- Or wrap (no `for` needed) -->
<label>
  Email
  <input name="email" type="email">
</label>

<!-- Group related inputs -->
<fieldset>
  <legend>Shipping address</legend>
  <label>Street <input name="street"></label>
  <label>City   <input name="city"></label>
</fieldset>

<!-- Error messages — programmatic association so SR reads them on focus -->
<label for="pw">Password</label>
<input id="pw" type="password" aria-invalid="true"
       aria-describedby="pw-err pw-hint">
<p id="pw-hint">At least 8 characters.</p>
<p id="pw-err" role="alert">Too short.</p>

<!-- autocomplete attributes help SR and password managers alike -->
<input autocomplete="current-password">
<input autocomplete="one-time-code">
<input autocomplete="cc-number">

ARIA Live Regions

<!-- Announces dynamic content to screen readers without moving focus -->

<!-- aria-live="polite" — wait until SR is idle -->
<div aria-live="polite">Saved!</div>

<!-- aria-live="assertive" — interrupt immediately. Use sparingly. -->
<div role="alert">Connection lost</div>   <!-- role=alert = assertive -->

<!-- aria-live="off" / removal — silent -->

<!-- aria-atomic="true" — read the whole region, not just the diff -->
<div aria-live="polite" aria-atomic="true">
  <p>3 of 10 items</p>
</div>

<!-- Gotcha: the live region must exist in the DOM BEFORE the content
     gets inserted. Mounting an empty <div role="alert"> on first use
     and then changing its text is the reliable pattern. -->

Common ARIA Attributes

Attribute             Use
────────────────────  ──────────────────────────────────────────────
role="..."            Override or fill in missing semantics
aria-label            Accessible name when no visible label
aria-labelledby="id"  Reference visible text as the name
aria-describedby=id   Extra description (hint, error)
aria-expanded         Disclosure / accordion / dropdown state
aria-controls="id"    What this control toggles
aria-current="page"   Marks the active item in a list
aria-hidden="true"    Hide decorative element from assistive tech
aria-disabled="true"  Disabled but still focusable (vs. `disabled`)
aria-pressed          Toggle button state
aria-selected         Selected option in listbox / tab
aria-modal="true"     On a dialog — traps SR cursor inside

Avoid: aria-role (it's `role`), redundant aria-label on <button>Save</button>.

Keep your own version of these notes — editable, searchable, and organised by your stack.

Start free