Semantic HTML is the cheapest accessibility win
Every div you replace with the element that means what you mean deletes a pile of ARIA, keyboard handling, and focus code you would otherwise have to write and maintain.
In one sentence
Every element you pick for its meaning rather than its appearance deletes code you would otherwise have to write, test, and keep working.
Why it matters
Assistive technology does not read your CSS. It reads the accessibility tree,
which the browser derives from your HTML: each node's role, name, state, and
position. A <div> has no role, so it contributes nothing.
The practical consequence is that a <div onclick> and a <button> look
identical and behave completely differently:
| Behaviour | <button> | <div onclick> |
|---|---|---|
| Reachable by Tab | Yes | Only with tabindex="0" |
| Fires on Enter and Space | Yes | Only if you handle keydown |
| Announced as "button" | Yes | Only with role="button" |
| Disabled state respected | Yes | Only with aria-disabled + guard |
| Submits a form | Yes | Never |
| Shows a focus ring by default | Yes | No |
Rebuilding that faithfully takes about twenty lines and a test suite. Or you type
<button>.
The elements that pay for themselves
Landmarks. <header>, <nav>, <main>, <aside>, <footer>. Screen reader
users navigate by landmark the way you navigate by scrolling — it is the
difference between jumping to the content and tabbing through forty nav links on
every page. One <main> per page, and it must not be nested inside the others.
Headings. <h1>–<h6> in order, with no levels skipped. The heading list is
the table of contents for a page. Style them freely; a <h2> styled small is
fine, a <div class="heading-2"> is not.
<button> vs <a>. The rule is what happens next, not how it looks: an <a>
navigates and belongs in the browser's history, a <button> performs an action
on the current page. A link styled as a button is fine. A button that navigates
breaks middle-click, open-in-new-tab, and copy-link.
<label>. Every input needs one, and associating it also doubles the click
target — a usability win for everyone.
<!-- The explicit form. Works everywhere, survives refactors. -->
<label for="email">Email</label>
<input id="email" name="email" type="email" autocomplete="email" required />
Placeholder text is not a label. It disappears when the user types, fails contrast requirements at default styling, and is not reliably announced.
<fieldset> and <legend> for radio groups and checkbox groups, so the
group's question is announced with each option rather than five orphaned labels.
<table> with <th scope> for tabular data. A grid of divs loses row and
column association, which is the entire point of a table.
A worked example
Here is a pattern that shows up in nearly every codebase — a "card" that links to a detail page, with a secondary action:
<!-- Before: div soup with a click handler on the wrapper -->
<div class="card" onclick="location.href='/orders/42'">
<div class="card-title">Order #42</div>
<div class="card-meta">Shipped 3 days ago</div>
<div class="card-action" onclick="cancel(42); event.stopPropagation()">Cancel</div>
</div>
Not keyboard reachable, no roles, nested interactives, and the stopPropagation
is load-bearing. The semantic version:
<article class="card">
<h3><a href="/orders/42">Order #42</a></h3>
<p class="card-meta">Shipped <time datetime="2026-08-24">3 days ago</time></p>
<button type="button" onclick="cancel(42)">Cancel order #42</button>
</article>
Now Tab reaches the link then the button, Enter follows the link, Space presses
the button, the heading appears in the page outline, and the <time> element
gives the machine-readable date the visible text has abbreviated. The "make the
whole card clickable" effect is a CSS pseudo-element on the link, not a wrapper
handler:
.card {
position: relative;
}
.card a::after {
content: '';
position: absolute;
inset: 0;
}
.card button {
position: relative;
} /* stays above the link overlay */
The one ARIA rule
No ARIA is better than bad ARIA.
ARIA does not add behaviour. role="button" on a div changes what is announced
and nothing else — you still owe the keyboard handling, the focus management, and
the disabled state. Reach for it only when the platform has no element for what
you are building, and then implement the full pattern the APG describes.
The exception worth using freely is aria-label / aria-labelledby for naming
things that have no visible text, like an icon-only button:
<button type="button" aria-label="Close dialog">
<svg aria-hidden="true" focusable="false"><!-- … --></svg>
</button>
Note aria-hidden on the icon. An unlabelled decorative SVG can otherwise be
announced as a meaningless graphic node.
Common pitfalls
outline: nonewith nothing to replace it. If you dislike the default ring, style:focus-visible— do not delete it.- Skipping heading levels to get the font size you wanted. Style it instead.
<div role="list">with<div role="listitem">when<ul>/<li>exists.- Nesting interactive elements — a button inside a link is invalid and behaves differently in every browser.
tabindexabove 0. It jumps the element to the front of the tab order globally and reliably makes the page worse. Use0or-1, nothing else.- Testing only with automated tools. Linters catch perhaps a third of real issues. Tab through the page; then turn the screen off and try it with a screen reader.
Further reading
- How to Meet WCAG 2.2 — filterable success criteria with techniques and failures.
- ARIA Authoring Practices Guide — the full keyboard and focus contract for each widget pattern.
- HTML elements reference — worth skimming end to end once; there are more elements than you are using.