Introduction

Run axe DevTools on your site right now. Seriously, right now — I'll wait. If you got zero violations, close this tab. If you got 15+, keep reading.

Most of those violations will trace back to the same five or six markup mistakes. Wrong elements. Missing labels. Broken heading hierarchy. And the fixes take minutes, not days. The problem was never difficulty. It was that nobody on the team thought to check.

What follows is organized by impact. Highest-violation fixes first.

Why Semantic HTML Matters

Accessibility: The Web Is for Everyone

Screen readers read the DOM, not the pixels. A <nav> gets announced as navigation. Users jump straight to it. A <div class="nav">? Silent. Invisible. The user tabs through every element on the page hunting for links that the developer buried in meaningless containers.

The WHO estimates that 1.3 billion people, about 16% of the world population, experience significant disability. And in the US, EU, and Australia, accessibility is backed by law, not just good intentions. Inaccessible sites carry real legal risk, and "we didn't know" is not a defense.

SEO: Search Engines Read Your Markup

Google's crawlers use your HTML structure to figure out what a page is about. Proper <h1> through <h6> headings, <article> elements, <nav> — these are free ranking signals. No plugin required. Most developers ignore this and then pay for SEO tools that solve a problem the markup could have solved from the start.

Maintainability: Code That Explains Itself

Which codebase would you rather inherit?

html
<!-- Non-semantic: What is what? -->
<div class="top-bar">
  <div class="top-bar-links">
    <a href="/">Home</a>
    <a href="/pages/about.html">About</a>
  </div>
</div>
<div class="content-area">
  <div class="main-text">...</div>
  <div class="side-stuff">...</div>
</div>
<div class="bottom-bar">...</div>

<!-- Semantic: Self-documenting -->
<header>
  <nav>
    <a href="/">Home</a>
    <a href="/pages/about.html">About</a>
  </nav>
</header>
<main>
  <article>...</article>
  <aside>...</aside>
</main>
<footer>...</footer>

Shorter. Self-documenting. No comments needed. Someone touching this code six months later reads the element names and understands the page. That is the actual argument for semantic HTML — not purity, not standards compliance. It is that <header> communicates what <div class="top-bar"> never will.

Landmark Elements

Landmarks are the single highest-impact fix for screen reader accessibility. They let users skip between page regions instantly. Without them, navigating your site means crawling through every element sequentially.

The skeleton:

html
<body>
  <header>
    <!-- Site logo, navigation, search -->
    <nav aria-label="Primary">
      <ul>
        <li><a href="/">Home</a></li>
        <li><a href="/pages/tutorials.html">Blog</a></li>
        <li><a href="/pages/contact.html">Contact</a></li>
      </ul>
    </nav>
  </header>

  <main>
    <article>
      <h1>Article Title</h1>
      <section aria-labelledby="intro-heading">
        <h2 id="intro-heading">Introduction</h2>
        <p>Article content goes here...</p>
      </section>
    </article>

    <aside aria-label="Sidebar">
      <!-- Related content, ads, table of contents -->
      <nav aria-label="Table of Contents">
        <ul>...</ul>
      </nav>
    </aside>
  </main>

  <footer>
    <!-- Copyright, secondary nav, social links -->
    <nav aria-label="Footer">
      <ul>...</ul>
    </nav>
  </footer>
</body>

Notice the aria-label attributes on the three <nav> elements. A screen reader announces "navigation" three times. Without labels the user cannot tell which is which. "Primary," "Table of Contents," "Footer" — those are not decoration. They are the only way a non-sighted user distinguishes navigation regions. Most developers skip this. Most accessibility audits flag it.

One <main> per page. Never nested inside <header>, <footer>, or <nav>.

And the <article> versus <section> question that comes up constantly: if the content makes sense in an RSS feed on its own, it is an <article>. Blog posts, news stories, product cards. A <section> is a thematic grouping inside a page and should always have a heading. Not a generic wrapper. That is what <div> is for, and nobody seems to remember that <div> is not a dirty word — it is the correct choice when no semantic element applies.

Headings, Lists and Text Semantics

In WebAIM's Screen Reader User Survey #10 (December 2023 to January 2024), 71.6% of respondents said navigating by headings is the first thing they do to find information on a long page. Not links. Not landmarks. Headings.

Your heading structure is how most assistive technology users experience your site. Get it wrong and they are lost.

Heading Hierarchy

One <h1> per page. <h2> through <h6> in order. Never skip levels. Do not jump from <h2> to <h4> because you liked <h4>'s default font size. CSS controls styling. Heading levels control structure. Different jobs entirely.

html
<!-- Correct heading hierarchy -->
<h1>Semantic HTML and Web Accessibility</h1>
<h2>Why Semantic HTML Matters</h2>
<h3>Accessibility Benefits</h3>
<h3>SEO Benefits</h3>
<h2>Landmark Elements</h2>
<h3>The Header Element</h3>
<h3>The Nav Element</h3>

<!-- Incorrect: skips from h2 to h4 -->
<h2>Section Title</h2>
<h4>This should be an h3!</h4>
Terminal running an axe-core check: headings.html fails heading-order on the h4 element, headings-fixed.html reports 0 violations.
Real output. axe-core flags the h2-to-h4 jump; with only the correct hierarchy the check passes.

Lists: More Than Bullet Points

Criminally underused. When a screen reader encounters a <ul> or <ol>, it announces the item count. The user immediately knows how much content lies ahead. Navigation menus should always be lists. That is literally what they are.

<ul> for unordered collections. <ol> for ordered sequences. <dl> for name-value pairs. Most developers forget <dl> exists.

Inline Text Semantics

<strong> and <b> look identical in a browser. They are not the same thing. <strong> signals importance — screen readers may change tone of voice for it. <b> is cosmetic. No semantic weight. Same distinction between <em> and <i>. Matters more than most teams realize.

Accessible Forms

Forms break accessibility more than anything else. And forms are where accessibility failures cost actual money. An e-commerce checkout where every input uses placeholder text instead of labels, error messages float unlinked, and the submit button is a styled <div> — screen reader users literally cannot buy anything. But the fix takes maybe twenty minutes.

Labels: The Non-Negotiable

An accessible form starts with one rule: every input gets a <label> connected via the for attribute. Not a placeholder. Not a nearby <span>. Placeholders vanish the moment you start typing. A real <label> element, linked programmatically. The autocomplete attribute helps browsers and password managers. The required attribute gives you native validation that works even when JavaScript fails. And aria-describedby links hint text to the input so screen readers announce it on focus. Here is a properly built email field:

html
<!-- Bad: placeholder as label -->
<input type="email" placeholder="Email address">

<!-- Good: proper label association -->
<label for="user-email">Email address</label>
<input type="email" id="user-email" name="email" autocomplete="email" required aria-describedby="email-hint">
<p id="email-hint" class="hint">
  We will never share your email with third parties.
</p>
Terminal running an axe-core check on the placeholder-only input and the labelled input: both report 0 violations, with a note that axe accepts placeholders as names.
Real output. axe-core passes both versions, which is why placeholder-only fields need a manual review.

Standard stuff.

Fieldsets and Legends: Grouping Related Inputs

Radio buttons without a <fieldset> and <legend>? A screen reader user hears each option in isolation. No context. No idea what the group of choices represents.

html
<form action="/checkout" method="POST" novalidate>
  <fieldset>
    <legend>Shipping Method</legend>

    <div>
      <input type="radio" id="ship-standard" name="shipping" value="standard" checked>
      <label for="ship-standard">
        Standard (5-7 business days) - Free
      </label>
    </div>

    <div>
      <input type="radio" id="ship-express" name="shipping" value="express">
      <label for="ship-express">
        Express (2-3 business days) - $12.99
      </label>
    </div>

    <div>
      <input type="radio" id="ship-overnight" name="shipping" value="overnight">
      <label for="ship-overnight">
        Overnight (next business day) - $24.99
      </label>
    </div>
  </fieldset>

  <button type="submit">Continue to Payment</button>
</form>

Error Messages: Be Specific and Programmatic

A red border is not an error message. Color alone conveys nothing to a screen reader and nothing to a colorblind user. Use aria-invalid="true" on the field, aria-describedby to link the error text, and aria-live="polite" on the error container so screen readers announce changes.

Set aria-invalid only after validation fails, and put the message in an element that is already in the DOM so the live region announces the change:

html
<label for="signup-email">Email address</label>
<input type="email" id="signup-email" name="email"
       autocomplete="email" required
       aria-invalid="true" aria-describedby="signup-email-error">
<p id="signup-email-error" class="field-error" aria-live="polite">
  Please enter an email in the format name@example.com
</p>

"Invalid input" tells the user nothing.

"Please enter an email in the format name@example.com" tells them exactly what to fix.

The difference between those two strings is the difference between a user completing the form and a user leaving your site.

ARIA Roles and Attributes

The first rule of ARIA is do not use ARIA.

Not a joke. A <div role="button" tabindex="0"> is strictly worse than a <button>. The div needs JavaScript for keyboard handling, ARIA for states, CSS for focus styles. The button gets all of that natively. Bad ARIA is worse than no ARIA. Sprinkling ARIA attributes across everything can leave a page less accessible than plain <div> soup.

When ARIA Is Appropriate

Custom widgets with no native HTML equivalent. That is basically it. The attributes worth knowing:

  • aria-label provides an accessible name when there is no visible text. Good for icon-only buttons: <button aria-label="Close dialog">.
  • aria-labelledby points to another element as the label. Common on sections: <section aria-labelledby="section-heading">.
  • aria-describedby links supplementary text to an element — perfect for form hints and error messages.
  • aria-expanded tells assistive tech whether a collapsible element is open or closed. Accordions, dropdowns, hamburger menus.
  • aria-hidden="true" removes an element from the accessibility tree entirely. Decorative icons, duplicate content, elements visually hidden but still in the DOM.
  • aria-live announces dynamic content changes. Use "polite" for non-urgent updates and "assertive" for critical alerts.

A Real-World ARIA Example: Accordion Component

This is what ARIA is actually for.

html
<div class="accordion">
  <h3>
    <button type="button" aria-expanded="false" aria-controls="faq-1-content" id="faq-1-trigger">
      What is semantic HTML?
      <svg aria-hidden="true" class="icon-chevron">...</svg>
    </button>
  </h3>
  <div id="faq-1-content" role="region" aria-labelledby="faq-1-trigger" hidden>
    <p>
      Semantic HTML means using HTML elements for their
      intended purpose. A <nav> for navigation,
      a <button> for actions, a <table> for
      tabular data, and so on.
    </p>
  </div>
</div>

aria-expanded communicates open/closed state. aria-controls links the button to the panel. aria-hidden="true" on the chevron keeps screen readers from announcing a decorative SVG. And the hidden attribute on the panel natively removes it from both display and accessibility tree. No custom CSS hiding tricks needed.

Keyboard Navigation and Focus Management

Not everyone uses a mouse. Motor disabilities. Power users who refuse to touch one. Switch devices that simulate keyboard input. If your site cannot be fully operated by keyboard, those people are locked out.

The Basics: Tab Order and Focus Visibility

Links, buttons, and form inputs are natively focusable. DOM order determines tab order. Two mistakes come up constantly: removing the focus outline and setting tabindex values above zero.

Both are worse than doing nothing.

css
/* Hide the outline only when the browser decides focus came from a mouse */
:focus:not(:focus-visible) {
  outline: none;
}

/* Visible focus for keyboard users */
:focus-visible {
  outline: 3px solid #4A90D9;
  outline-offset: 2px;
  border-radius: 4px;
}

/* Custom focus style for buttons */
.btn:focus-visible {
  outline: 3px solid #4A90D9;
  outline-offset: 2px;
  box-shadow: 0 0 0 6px rgba(74, 144, 217, 0.25);
}

:focus-visible matches when the browser decides a focus ring should be shown, which is keyboard navigation in practice, not mouse clicks. Removing the outline only under :focus:not(:focus-visible) means a browser that does not understand :focus-visible keeps its default ring instead of losing it. That settles most of the "designers hate the focus ring" argument.

Focus Trapping in Modals

When a modal opens, focus moves into it, and the rest of the page must not be reachable until it closes. Escape closes the modal and focus returns to the trigger.

The native <dialog> element handles this when you call .showModal(): it moves focus into the dialog, makes the rest of the page inert, closes on Escape, and restores focus to the previously focused element. It does not trap Tab in a strict loop, though. Browsers may let focus move out to the browser's own toolbar, which is expected and fine. Still building modals with <div> elements and custom focus trap JavaScript? Try the native element first.

html
<!-- Native modal dialog: focus moves in, the page behind becomes inert -->
<button type="button" id="open-dialog" onclick="document.getElementById('my-dialog').showModal()">
  Delete Account
</button>

<dialog id="my-dialog" aria-labelledby="dialog-title">
  <h2 id="dialog-title">Confirm Deletion</h2>
  <p>
    Are you sure you want to delete your account?
    This action cannot be undone.
  </p>
  <div class="dialog-actions">
    <button type="button" onclick="this.closest('dialog').close()">
      Cancel
    </button>
    <!-- deleteAccount() is your own app function -->
    <button type="button" class="btn-danger" onclick="deleteAccount()">
      Yes, Delete My Account
    </button>
  </div>
</dialog>

Skip Navigation Links

A "skip to main content" link. First focusable element in <body>. Visually hidden, visible on focus. Without it, keyboard users tab through your entire navigation on every single page load. Tiny fix. Huge difference.

html
<body>
  <a href="#main-content" class="skip-link">Skip to main content</a>
  <header>...</header>
  <main id="main-content" tabindex="-1">...</main>
</body>
css
.skip-link {
  position: absolute;
  left: 1rem;
  top: -100px;
  padding: 0.5rem 1rem;
  background: #ffffff;
  color: #1a1a1a;
  z-index: 1000;
}

.skip-link:focus {
  top: 1rem;
}

tabindex="-1" on <main> lets it receive focus programmatically when the link is followed, so the next Tab continues from the content instead of the top of the page.

Testing Accessibility

Writing the markup is half the work. The other half is proving it works.

Automated Testing Tools

How much automation catches depends on how you count. Only around a third of WCAG 2.1 AA success criteria can be checked automatically, but Deque's analysis of its own audit data found automated axe-core tests caught 57% of issues by volume, because common problems like contrast and missing labels are easy to detect. Either way, a useful first pass, not sufficient on its own.

axe DevTools is the one to install first. It scans the page and reports violations with severity and fix suggestions. Lighthouse has an accessibility audit built in. For React, eslint-plugin-jsx-a11y catches missing alt attributes and invalid ARIA at the code level. Pa11y runs from the command line against a URL — put it in your CI pipeline and accessibility regressions fail the build automatically. But none of these tools will tell you that your tab order makes no sense or that your modal traps focus wrong. That requires a human.

Manual Testing: The Keyboard Test

Unplug the mouse. This one exercise finds more problems than most developers expect:

  1. Can you tab to every interactive element (links, buttons, inputs, selects)?
  2. Is the focus indicator always visible? Can you always tell where you are?
  3. Does the tab order follow a logical sequence that matches the visual layout?
  4. Can you activate buttons with Enter and Space?
  5. Can you open and close dropdowns, modals, and menus? Does pressing Escape close them?
  6. Can you navigate radio groups and select options with arrow keys?
  7. After a modal closes, does focus return to the element that opened it?

Screen Reader Testing

Spend an hour navigating websites with a screen reader. Just once. It permanently changes how you write markup.

Mac: VoiceOver, built in, Cmd+F5. Windows: NVDA, free, pairs best with Firefox. Android: TalkBack, built in.

Listen for whether landmarks get announced. Whether headings communicate structure. Whether form fields have labels. Whether error messages surface when they appear. If any of those fail, you know exactly what to fix. The gap between what you thought was accessible and what actually is can be embarrassing, and that discomfort is productive because it is the thing that makes you write better HTML going forward instead of just reading articles about it.

Checklist

  • Run axe DevTools on every page before merging a PR. Fix anything flagged critical or serious.
  • One <h1> per page, no skipped heading levels, headings in order.
  • Every form input has a <label> with a for attribute. No placeholder-only fields.
  • Every <img> has an alt attribute. Decorative images get alt="".
  • Tab through the entire page with the keyboard. Every interactive element reachable, focus always visible.
  • Navigate the page with a screen reader once a quarter. VoiceOver or NVDA. No excuses.
AS

Anurag Sinha

Founder & Lead Developer

Anurag is a full stack developer and self-taught programmer from India with over five years of experience building web applications, APIs, and cloud-native systems. He researches, writes and reviews every tutorial on Codertronix, and updates them when the tools change.