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:
- Use semantic HTML first
- Add ARIA only to expose missing meaning, state, or relationships
- 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:
- Is there a native element that already does this?
- If yes, can I use it without restyling hacks?
- If no, what meaning/state/relationship is missing for assistive tech?
Examples:
- Use
<button>instead of<div role="button"> - Use
<label for="email">instead ofaria-labelon 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.
| Attribute | What it means | Use it when | Avoid it when |
|---|---|---|---|
aria-label | Accessible name from a string | Control has no visible label (for example icon-only button) | A visible label already exists |
aria-labelledby | Accessible name from another element | Existing visible heading/label should name the control | You can use native <label> on form fields |
aria-describedby | Additional helper text | You need to attach hint/error text to a control | The extra text is not relevant to task completion |
aria-expanded | Disclosure state | A button toggles a menu, accordion, or panel | Element is not a toggle trigger |
aria-controls | Relationship to controlled region | Trigger clearly controls a specific region ID | You are trying to make unrelated elements seem connected |
aria-current | Current item in a set | Active nav link, current step, current date in calendar | Generic selected styling |
aria-selected | Item selected in composite widget | Tabs, listbox, grid options | Plain nav links or buttons |
aria-pressed | Toggle button state | One button toggles on/off state (mute, bold, pin) | Normal one-shot actions |
aria-live | Dynamic updates should be announced | New non-focus-changing content appears | Static text or high-frequency noisy updates |
aria-modal | Active dialog blocks outside interaction | Custom role="dialog" modal implementation | You are using native <dialog> correctly and already trapping focus |
aria-hidden | Hide from accessibility tree | Decorative icons or duplicated visible text | Interactive elements or meaningful content |
aria-busy | Region is updating | Async content refresh is in progress | No 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:
- Can I replace this ARIA with native HTML?
- Are ARIA states (
aria-expanded,aria-pressed,aria-busy) always in sync? - Does keyboard interaction work end-to-end (including Escape/Tab in dialogs)?
- Are live regions informative without being noisy?
- Does the UI respect
prefers-reduced-motionand 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.