Frontend Inbox

Semantic HTML is the cheapest accessibility win

Beginner7 minUpdated 2026-08-27

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.

#html
#a11y

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 TabYesOnly with tabindex="0"
Fires on Enter and SpaceYesOnly if you handle keydown
Announced as "button"YesOnly with role="button"
Disabled state respectedYesOnly with aria-disabled + guard
Submits a formYesNever
Shows a focus ring by defaultYesNo

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: none with 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.
  • tabindex above 0. It jumps the element to the front of the tab order globally and reliably makes the page worse. Use 0 or -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