ARIA vs Semantic HTML: Practical Accessibility Patterns for Dialogs and Chat UIs

ARIA vs Semantic HTML: Practical Accessibility Patterns for Dialogs and Chat UIs

~ 5 min read


Why this still trips teams up

Teams usually know accessibility matters, but many codebases still swing between two extremes:

  • Adding ARIA everywhere, including where it is not needed
  • Avoiding ARIA completely, even for complex widgets that require it

The practical middle ground is simple:

  1. Use semantic HTML first
  2. Add ARIA only to expose missing meaning, state, or relationships
  3. Keep ARIA in sync with real UI behaviour

If ARIA says one thing and the UI does another, assistive technology users lose trust quickly.

A simple decision rule: semantic first, ARIA second

Before adding any ARIA attribute, ask:

  1. Is there a native element that already does this?
  2. If yes, can I use it without restyling hacks?
  3. If no, what meaning/state/relationship is missing for assistive tech?

Examples:

  • Use <button> instead of <div role="button">
  • Use <label for="email"> instead of aria-label on a text input with visible label text
  • Use <nav>, <main>, and <header> instead of generic <div> landmarks

What common ARIA attributes mean (and when to use them)

These are the attributes most teams actually need in day-to-day work.

AttributeWhat it meansUse it whenAvoid it when
aria-labelAccessible name from a stringControl has no visible label (for example icon-only button)A visible label already exists
aria-labelledbyAccessible name from another elementExisting visible heading/label should name the controlYou can use native <label> on form fields
aria-describedbyAdditional helper textYou need to attach hint/error text to a controlThe extra text is not relevant to task completion
aria-expandedDisclosure stateA button toggles a menu, accordion, or panelElement is not a toggle trigger
aria-controlsRelationship to controlled regionTrigger clearly controls a specific region IDYou are trying to make unrelated elements seem connected
aria-currentCurrent item in a setActive nav link, current step, current date in calendarGeneric selected styling
aria-selectedItem selected in composite widgetTabs, listbox, grid optionsPlain nav links or buttons
aria-pressedToggle button stateOne button toggles on/off state (mute, bold, pin)Normal one-shot actions
aria-liveDynamic updates should be announcedNew non-focus-changing content appearsStatic text or high-frequency noisy updates
aria-modalActive dialog blocks outside interactionCustom role="dialog" modal implementationYou are using native <dialog> correctly and already trapping focus
aria-hiddenHide from accessibility treeDecorative icons or duplicated visible textInteractive elements or meaningful content
aria-busyRegion is updatingAsync content refresh is in progressNo real loading/update state exists

Two frequent mistakes:

  • Adding role="button" to a real <button>
  • Using aria-live="assertive" for routine updates (too disruptive)

Semantic HTML is usually enough

This version is already accessible for most users:

<form>
    <label for="email">Email address</label>
    <input id="email" name="email" type="email" autocomplete="email" required />
    <button type="submit">Subscribe</button>
</form>

There is no need for extra ARIA here. Native form semantics and labels already do the job.

Tricky pattern 1: dialogs

Dialogs are where teams often overuse and underuse ARIA at the same time.

Preferred: native <dialog>

<button id="open-settings" type="button">Open settings</button>

<dialog id="settings-dialog" aria-labelledby="settings-title">
    <h2 id="settings-title">Settings</h2>

    <form method="dialog">
        <label for="theme">Theme</label>
        <select id="theme" name="theme">
            <option>System</option>
            <option>Light</option>
            <option>Dark</option>
        </select>

        <button value="cancel">Close</button>
        <button value="save">Save</button>
    </form>
</dialog>
const openBtn = document.getElementById("open-settings");
const dialog = document.getElementById("settings-dialog");

openBtn.addEventListener("click", () => dialog.showModal());
dialog.addEventListener("close", () => openBtn.focus());

Why this works:

  • Native modal behaviour with backdrop
  • Clear programmatic name via aria-labelledby
  • Focus returns to opener after close

If you must build a custom modal

Use role="dialog" plus aria-modal="true", and implement focus trap + Escape close yourself.

<div
    id="custom-dialog"
    role="dialog"
    aria-modal="true"
    aria-labelledby="custom-dialog-title"
    hidden
>
    <h2 id="custom-dialog-title">Preferences</h2>
    <!-- dialog content -->
</div>

If focus can escape to background controls, the modal is not actually accessible, no matter how good the ARIA looks.

Tricky pattern 2: chat interfaces

Chat UIs are dynamic, stateful, and easy to make noisy for screen reader users.

A practical chat structure

<section aria-labelledby="chat-title">
    <h2 id="chat-title">Support chat</h2>

    <div
        id="messages"
        role="log"
        aria-live="polite"
        aria-relevant="additions text"
        aria-busy="false"
    ></div>

    <p id="chat-help">Messages are announced as they arrive.</p>

    <form aria-describedby="chat-help">
        <label for="chat-input">Message</label>
        <input id="chat-input" name="message" />
        <button type="submit">Send</button>
    </form>

    <div id="chat-status" role="status" aria-live="polite"></div>
</section>

Update semantics with real state

const log = document.getElementById("messages");
const status = document.getElementById("chat-status");

async function sendMessage(text) {
    appendMessage("You", text);
    log.setAttribute("aria-busy", "true");
    status.textContent = "Assistant is typing";

    try {
        const reply = await getReply(text);
        appendMessage("Assistant", reply);
        status.textContent = "Assistant replied";
    } catch (error) {
        console.error(error);
        status.textContent = "Assistant could not reply. Please try again.";
    } finally {
        log.setAttribute("aria-busy", "false");
    }
}

Notes:

  • Use role="log" for appended conversational entries
  • Keep updates polite unless genuinely urgent
  • Do not announce every token in a streaming response; announce completed chunks

Motion and contrast preferences are part of accessibility

Accessibility is not only semantics and ARIA. Honour user system preferences too.

Reduced motion

/* Baseline animation */
.toast {
    animation: slide-up 220ms ease-out;
}

@media (prefers-reduced-motion: reduce) {
    * {
        animation-duration: 1ms !important;
        animation-iteration-count: 1 !important;
        transition-duration: 1ms !important;
        scroll-behavior: auto !important;
    }
}

Keep meaning, reduce motion intensity. Do not remove critical state changes entirely.

Higher contrast and forced colours

:root {
    --surface: #ffffff;
    --text: #1a1a1a;
    --focus: #005fcc;
}

@media (prefers-contrast: more) {
    :root {
        --text: #000000;
        --focus: #003b8f;
    }

    .card {
        border: 2px solid currentColor;
    }
}

@media (forced-colors: active) {
    .button {
        forced-color-adjust: auto;
    }
}

Also ensure focus indicators remain clearly visible in all themes and contrast modes.

A fast QA checklist for pull requests

Before merging, validate:

  1. Can I replace this ARIA with native HTML?
  2. Are ARIA states (aria-expanded, aria-pressed, aria-busy) always in sync?
  3. Does keyboard interaction work end-to-end (including Escape/Tab in dialogs)?
  4. Are live regions informative without being noisy?
  5. Does the UI respect prefers-reduced-motion and higher-contrast settings?

Run both manual keyboard checks and at least one screen reader pass on the trickiest flows.

Final takeaway

ARIA is not a badge of accessibility. It is a precision tool.

When semantics are correct, you need less ARIA.
When the UI is complex (dialogs, chat, composite widgets), ARIA fills the gaps.

The goal is not “more ARIA”. The goal is predictable, understandable behaviour for real users.

all posts →