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…
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>.