Polyglot
Languages Web (HTML / CSS / JS) html accessibility
Web (HTML / CSS / JS) § html-accessibility

Semantic and accessibility

Semantic HTML admits substantial structure that assistive technologies (screen readers, voice navigation, keyboard-only navigation) consume to present the page meaningfully. The principal mechanisms: HTML5 semantic elements (<header>, <nav>, <main>, <article>, <section>, <footer>, etc.), ARIA (Accessible Rich Internet Applications) attributes for substantial dynamic content, and substantial keyboard accessibility through tabindex and focus management. The Web Content Accessibility Guidelines (WCAG) define conformance levels (A, AA, AAA); the conventional contemporary target is WCAG 2.1 AA. The combination — semantic markup, ARIA augmentation, keyboard support, the substantial alt/label/role discipline — is the substance of HTML’s accessibility surface.

Semantic elements

HTML5 admits substantial sectioning elements:

<header>                                          <!-- page or section header -->
<nav>                                             <!-- navigation links -->
<main>                                            <!-- the main content (one per page) -->
<article>                                         <!-- self-contained content -->
<section>                                         <!-- thematic section -->
<aside>                                           <!-- tangentially-related content -->
<footer>                                          <!-- page or section footer -->

<details>                                         <!-- collapsible content -->
    <summary>Click to expand</summary>
    <p>Details here.</p>
</details>

<dialog>                                          <!-- modal or non-modal dialog -->
    <p>Dialog content</p>
    <button onclick="this.closest('dialog').close()">Close</button>
</dialog>

The principal benefits:

  • Screen readers — announce sections by their role.
  • Browser features — admit reader mode, search, navigation.
  • Maintainability — substantial readability vs <div> soup.

Headings hierarchy

Headings (<h1><h6>) admit a hierarchical outline:

<h1>Site title</h1>                                <!-- Once per page -->

<main>
    <article>
        <h2>Article title</h2>                     <!-- One per article -->
        <p>...</p>

        <h3>Section heading</h3>
        <p>...</p>

        <h3>Another section</h3>
        <p>...</p>
    </article>
</main>

The conventional discipline:

  • One <h1> per page — the principal heading.
  • Don’t skip levels<h1> followed by <h3> admits substantial confusion.
  • Use headings for structure, not for styling — substantial semantic meaning.
  • Don’t use <h> for non-headings — admit substantial confusion for screen readers.

Images

The alt attribute is required for accessibility:

<!-- Informative image: describe content -->
<img src="chart.png" alt="Q1 sales by region: North 40%, South 30%, East 20%, West 10%">

<!-- Decorative image: empty alt admits screen readers skipping -->
<img src="ornament.png" alt="">

<!-- Functional image (link/button): describe the action -->
<a href="/home">
    <img src="logo.png" alt="Home">
</a>

For substantial complex images (charts, diagrams), the conventional patterns:

<figure>
    <img src="chart.png" alt="Q1 sales chart" aria-describedby="chart-desc">
    <figcaption>
        Q1 sales by region.
        <span id="chart-desc" class="sr-only">
            Detailed description: North region had 40% of sales, ...
        </span>
    </figcaption>
</figure>

Forms

Treated in Forms and inputs.

The principal accessibility patterns:

<!-- Always label inputs: -->
<label for="email">Email</label>
<input type="email" id="email" name="email" required
       aria-required="true"
       aria-describedby="email-hint">
<small id="email-hint">We'll never share your email.</small>

<!-- For visually hidden labels: -->
<label for="search" class="sr-only">Search</label>
<input type="search" id="search" name="q">

<!-- Required indicator: -->
<label for="name">Name <span aria-hidden="true">*</span></label>
<input type="text" id="name" name="name" required aria-required="true">

<!-- Error messaging: -->
<input type="email" id="email" aria-invalid="true" aria-describedby="email-error">
<div id="email-error" role="alert">Please enter a valid email.</div>

The sr-only (screen-reader only) class — the conventional CSS pattern:

.sr-only {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip: rect(0, 0, 0, 0);
    white-space: nowrap;
    border: 0;
}

ARIA

Accessible Rich Internet Applications attributes admit substantial substrate for dynamic content. The principal categories:

Roles

<div role="navigation">                            <!-- nav element preferred -->
<div role="main">                                  <!-- main element preferred -->
<div role="article">                               <!-- article element preferred -->

<!-- Widget roles (for substantial custom components): -->
<div role="button" tabindex="0">Click me</div>     <!-- button element preferred -->
<div role="dialog" aria-labelledby="dialog-title">
<ul role="tablist">
    <li role="tab" aria-selected="true">Tab 1</li>
    <li role="tab" aria-selected="false">Tab 2</li>
</ul>
<div role="tabpanel" aria-labelledby="tab-1">Content</div>

The principal rule: use semantic HTML elements first. ARIA roles are admitted for substantial custom components or polyfills.

Properties

<button aria-label="Close dialog">×</button>      <!-- describe icon-only button -->
<button aria-labelledby="title-id">...</button>   <!-- reference another element's text -->
<input aria-describedby="hint-id">                <!-- additional description -->

<input type="text" aria-required="true" aria-invalid="false">

<button aria-expanded="false" aria-controls="menu-id">Menu</button>
<ul id="menu-id" hidden>
    <!-- menu items -->
</ul>

<div role="alert">Form validation error!</div>    <!-- live region -->
<div role="status" aria-live="polite">Saved.</div>

<button aria-pressed="true">Toggle</button>       <!-- toggle button state -->

<a href="..." aria-current="page">Current page</a>  <!-- in navigation -->

The principal ARIA properties:

PropertyMeaning
aria-labelOverride the accessible name
aria-labelledbyReference label text by element ID
aria-describedbyAdditional description
aria-requiredRequired field
aria-invalidInvalid state
aria-expandedExpanded/collapsed state
aria-pressedToggle button state
aria-currentCurrent item in a set
aria-hiddenHide from assistive tech
aria-liveAnnounce dynamic changes (polite, assertive)

Live regions

For dynamic content (notifications, status updates):

<div role="status" aria-live="polite">
    Form saved at 10:00.
</div>

<div role="alert" aria-live="assertive">
    Connection error!
</div>

<!-- Atomic updates (read entire region, not just changes): -->
<div aria-live="polite" aria-atomic="true">
    <p>Score: 5</p>
</div>

The aria-live admits screen readers announcing changes:

  • off (default) — no announcement.
  • polite — announce when idle.
  • assertive — interrupt current speech.

Keyboard accessibility

Every interactive element must be keyboard-accessible:

<!-- Native interactive elements are keyboard-accessible by default: -->
<button>I work with keyboard</button>
<a href="...">So do I</a>
<input type="text">

<!-- Custom interactive elements need tabindex: -->
<div tabindex="0" role="button">Custom button</div>

<!-- Skip links (for keyboard navigation): -->
<a href="#main" class="skip-link">Skip to main content</a>

<!-- Focus management: -->
<div tabindex="-1" id="error-region">                <!-- focusable but not in tab order -->
    Error: ...
</div>

<script>
    // Programmatic focus:
    document.getElementById("error-region").focus();
</script>

The principal tabindex values:

  • Positive — explicit tab order (avoid; substantial maintenance burden).
  • 0 — included in natural tab order.
  • -1 — focusable programmatically but not via Tab.

For focus-visible styling:

button:focus-visible {
    outline: 2px solid blue;
    outline-offset: 2px;
}

/* Don't remove focus indicators without alternative: */
button:focus {
    outline: none;                                 /* AVOID without replacement */
}

/* Conventional: */
button:focus {
    outline: none;
}
button:focus-visible {
    outline: 2px solid blue;
}
<body>
    <a href="#main" class="skip-link">Skip to main content</a>

    <header>
        <nav>
            <!-- Lots of nav links -->
        </nav>
    </header>

    <main id="main" tabindex="-1">
        <!-- Content -->
    </main>
</body>
.skip-link {
    position: absolute;
    top: -40px;
    left: 0;
    background: blue;
    color: white;
    padding: 8px;
    z-index: 100;
}

.skip-link:focus {
    top: 0;
}

Colour and contrast

The conventional contemporary advice:

  • Don’t use colour as the only signal — admit substantial colour-blind support.
  • Maintain sufficient contrast — WCAG AA: 4.5:1 for normal text, 3:1 for large text.
  • Test with contrast tools — substantial tooling (axe DevTools, WAVE, Lighthouse).
<!-- Bad: only colour indicates state -->
<p style="color: red">Required field</p>

<!-- Good: colour + icon + text -->
<p style="color: red">⚠ Required field <span class="sr-only">(error)</span></p>

Common patterns

Accessible button

<button type="button" aria-label="Close dialog">×</button>
<button type="button">
    <span aria-hidden="true">×</span>
    <span class="sr-only">Close</span>
</button>
<!-- Avoid generic text: -->
<a href="/article-1">Read more</a>                <!-- "Read more" is ambiguous -->

<!-- Better: descriptive link text -->
<a href="/article-1">Read more about HTML semantics</a>

<!-- Or with accessible label: -->
<a href="/article-1" aria-label="Read more about HTML semantics">Read more</a>

Accessible navigation

<nav aria-label="Main">
    <ul>
        <li><a href="/" aria-current="page">Home</a></li>
        <li><a href="/about">About</a></li>
        <li><a href="/contact">Contact</a></li>
    </ul>
</nav>

<nav aria-label="Breadcrumb">
    <ol>
        <li><a href="/">Home</a></li>
        <li><a href="/blog">Blog</a></li>
        <li aria-current="page">This article</li>
    </ol>
</nav>

Accessible modal dialog

<dialog id="confirm-dialog" aria-labelledby="dialog-title" aria-modal="true">
    <h2 id="dialog-title">Confirm action</h2>
    <p>Are you sure you want to delete this item?</p>
    <button onclick="this.closest('dialog').close('confirm')">Confirm</button>
    <button onclick="this.closest('dialog').close('cancel')">Cancel</button>
</dialog>

<button onclick="document.getElementById('confirm-dialog').showModal()">
    Delete
</button>

The native <dialog> admits substantial built-in accessibility (focus trap, Escape to close, etc.).

Accessible accordion

<button aria-expanded="false" aria-controls="section-1">
    Section 1
</button>
<div id="section-1" hidden>
    <p>Content...</p>
</div>

<script>
    document.querySelectorAll("button[aria-expanded]").forEach(btn => {
        btn.addEventListener("click", () => {
            const expanded = btn.getAttribute("aria-expanded") === "true";
            btn.setAttribute("aria-expanded", !expanded);
            const section = document.getElementById(btn.getAttribute("aria-controls"));
            section.hidden = expanded;
        });
    });
</script>

Accessible tabs

<div role="tablist" aria-label="Settings tabs">
    <button role="tab" id="tab-1" aria-selected="true" aria-controls="panel-1">
        Profile
    </button>
    <button role="tab" id="tab-2" aria-selected="false" aria-controls="panel-2" tabindex="-1">
        Settings
    </button>
</div>

<div role="tabpanel" id="panel-1" aria-labelledby="tab-1">
    Profile content
</div>
<div role="tabpanel" id="panel-2" aria-labelledby="tab-2" hidden>
    Settings content
</div>

Loading state

<button id="submit" aria-busy="false">Submit</button>
<div role="status" aria-live="polite" id="status"></div>

<script>
    const btn = document.getElementById("submit");
    const status = document.getElementById("status");

    btn.addEventListener("click", async () => {
        btn.setAttribute("aria-busy", "true");
        btn.disabled = true;
        status.textContent = "Submitting...";

        try {
            await submit();
            status.textContent = "Submitted successfully.";
        } catch (e) {
            status.textContent = "Error: " + e.message;
        } finally {
            btn.setAttribute("aria-busy", "false");
            btn.disabled = false;
        }
    });
</script>

Form with validation

<form>
    <div>
        <label for="email">Email</label>
        <input type="email" id="email" name="email"
               aria-required="true" aria-invalid="false"
               aria-describedby="email-hint email-error">
        <small id="email-hint">We'll never share your email.</small>
        <span id="email-error" role="alert"></span>
    </div>

    <button type="submit">Submit</button>
</form>

<script>
    const form = document.querySelector("form");
    const email = document.getElementById("email");
    const error = document.getElementById("email-error");

    form.addEventListener("submit", (e) => {
        if (!email.validity.valid) {
            e.preventDefault();
            email.setAttribute("aria-invalid", "true");
            error.textContent = "Please enter a valid email.";
            email.focus();
        }
    });

    email.addEventListener("input", () => {
        if (email.validity.valid) {
            email.setAttribute("aria-invalid", "false");
            error.textContent = "";
        }
    });
</script>

Accessible data table

<table>
    <caption>Quarterly revenue</caption>
    <thead>
        <tr>
            <th scope="col">Quarter</th>
            <th scope="col">Revenue</th>
            <th scope="col">Growth</th>
        </tr>
    </thead>
    <tbody>
        <tr>
            <th scope="row">Q1</th>
            <td>$100,000</td>
            <td>5%</td>
        </tr>
    </tbody>
</table>

The scope admits substantial screen-reader navigation.

<div role="region" aria-roledescription="carousel" aria-label="Product photos">
    <button aria-label="Previous slide" onclick="prev()">‹</button>

    <div aria-live="polite">
        <img src="photo-1.jpg" alt="Product front view" id="current-photo">
    </div>

    <button aria-label="Next slide" onclick="next()">›</button>

    <ul role="tablist" aria-label="Slides">
        <li role="tab" aria-selected="true" tabindex="0">1</li>
        <li role="tab" aria-selected="false" tabindex="-1">2</li>
    </ul>
</div>

Hide from assistive tech vs visually

<!-- Visually hidden, accessible to screen readers: -->
<span class="sr-only">Loading...</span>

<!-- Hidden from screen readers, visible: -->
<svg aria-hidden="true">...</svg>

<!-- Hidden from both: -->
<div hidden>...</div>
<div style="display: none">...</div>

The principal distinction: aria-hidden="true" admits hiding only from assistive tech (visible to sighted users); hidden and display: none admit hiding from both.

A note on testing

Tools and techniques:

  • axe DevTools (browser extension) — automated accessibility testing.
  • WAVE (browser extension) — visual accessibility checker.
  • Lighthouse (Chrome DevTools) — admits accessibility audits.
  • Screen readers — VoiceOver (Mac/iOS), NVDA (Windows), JAWS (Windows), TalkBack (Android).
  • Keyboard testing — Tab, Shift+Tab, Enter, Space, arrow keys.
  • Forced colours — test with high-contrast / dark themes.

The conventional discipline tests with multiple methods; automated tools catch ~30% of issues — substantial manual testing is required.

A note on the conventional discipline

The contemporary HTML accessibility advice:

  • Use semantic HTML before reaching for ARIA.
  • Provide alt for images — empty alt="" for decorative.
  • Associate <label> with form inputs.
  • Use heading hierarchy — one <h1> per page; don’t skip levels.
  • Maintain colour contrast — WCAG AA minimum.
  • Don’t rely on colour alone — add icons, text.
  • Make all functionality keyboard-accessible.
  • Use :focus-visible for clear focus indicators.
  • Use ARIA only when needed — first rule of ARIA: don’t use ARIA.
  • Provide skip links on substantial pages.
  • Use live regions (aria-live) for dynamic announcements.
  • Test with screen readers — at least one (NVDA, VoiceOver).
  • Run automated audits — axe, Lighthouse, WAVE.

The combination — semantic HTML, ARIA augmentation, keyboard support, the substantial alt/label/role discipline, the WCAG conformance levels, the principal testing practices — is the substance of HTML’s accessibility surface. The discipline produces substantial inclusive pages; the cost is substantial care during authoring, recovered through the substantial usability and substantial regulatory compliance benefits.