- Why semantic HTML and ARIA matter
- High-impact ARIA and semantic mistakes to stop shipping
- Precise code fixes: aria code examples that restore screen reader compatibility
- Accessible component patterns you can copy into your codebase
- Practical application: a step-by-step remediation checklist
Semantic HTML and correct ARIA usage are the difference between an interface that works for everyone and one that only looks right to sighted users. I triage dozens of production bugs where visuals are fine but assistive technologies either say nothing useful or read a confusing stream of attributes instead of an actionable control.
The problem you face looks familiar in triage: builds that pass automated scans but fail real-world usage. Widgets built from div/span with role sprinkled in frequently break keyboard flow, produce empty accessible names, or hide critical controls via aria-hidden. Those symptoms create support tickets, legal risk, and, most importantly, real exclusion of users who rely on screen readers and keyboard-only navigation .
Why semantic HTML and ARIA matter
Semantic HTML gives assistive technologies a reliable, well-understood starting point: a <button> is a button, an <a href> is a link, and a <form> control already wires up labels and keyboard behavior for you. The W3C’s guidance is explicit: use native HTML when it provides the semantics you need; add ARIA only when HTML lacks the required semantics or state .
A few pragmatic consequences you must internalize:
- Native controls provide implicit roles, focusability, keyboard behaviors, and accessible-name computation — all without extra JavaScript. That reduces bugs and maintenance cost.
- ARIA exists to extend semantics for custom widgets, not to replicate native HTML. Overriding or duplicating native semantics often produces confusing or contradictory output in assistive tech.
- Tools like axe, Lighthouse, and WAVE find many technical errors, but they can't replace human-driven screen reader and keyboard testing; automation is the first gate, not the finish line.
Important: When you choose ARIA, implement the full behavioral contract (keyboard handling, state updates, and focus management). Role-only fixes (e.g.,
role="button"on adivwith no keyboard handlers) are a common source of regression.
High-impact ARIA and semantic mistakes to stop shipping
Below are the high-frequency, high-impact mistakes I keep seeing in QA backlogs, with the why and the immediate red flag you should watch for.
- Using
role="button"on non-interactive elements instead of using<button>. Why it breaks: role alone does not add keyboard semantics or default focus. Red flag: visually clickable element that cannot be activated by Space/Enter with keyboard. - Applying
aria-hidden="true"to ancestors or to focusable elements. Why it breaks:aria-hiddenremoves content from the accessibility tree and will hide children even if they are focusable, creating “focus on nothing” traps. Red flag: screen reader and keyboard focus don’t match visual focus. - Adding
aria-labeloraria-labelledbythat overrides visible labels (and then forgetting to keep them in sync). Why it breaks: the accessible name algorithm gives precedence to author-supplied labels, so visible<label>text can be ignored whenaria-labelis present. Red flag: screen reader announces a different name than the label visible on screen. - Using
tabindexvalues greater than0. Why it breaks: positive tabindex reorders the natural document flow and creates unpredictable tab sequences. Red flag: keyboard order doesn’t follow reading or DOM order. - Declaring ARIA roles for complex widgets (e.g.,
role="menu",role="tree") without implementing the full keyboard and focus model required by the ARIA spec. Why it breaks: assistive tech expects specific behaviors; omitting those behaviors creates unusable widgets. Red flag: screen reader announces a widget type but arrow keys and focus behave like a static list. - Using
role="presentation"orrole="none"on elements that remain interactive. Why it breaks: those roles strip semantics and leave a focusable control with no name/role. Red flag: element is focusable but screen reader says nothing useful. - Misusing live regions (
aria-live) — too broad or too frequent announcements. Why it breaks: creates noisy speech causing distraction instead of helpful updates. Red flag: repeated announcements or the wrong content read by AT when dynamic updates occur.
Precise code fixes: aria code examples that restore screen reader compatibility
When triaging, I move from identifying the failing symptom to a minimal, testable code fix. Below are concrete before/after examples and the reasoning you can paste into PRs.
1) Replace div role="button" with a native button (preferred)
Wrong:
<!-- WRONG: not keyboard-sane or semantics-complete -->
<div role="button" onclick="save()" class="btn">Save</div>
Right:
<!-- RIGHT: native semantics, built-in keyboard behavior -->
<button type="button" class="btn" id="saveBtn">Save</button>
Why: <button> exposes role, keyboard activation, accessible name from content, and is supported consistently across AT and platforms.
2) If you absolutely must use a non-semantic element, implement the full contract
Wrong:
<!-- WRONG: role only -->
<span role="button" onclick="toggleFavorite()">★</span>
Right:
<!-- RIGHT: focusable + keyboard handlers + aria state -->
<span role="button" tabindex="0" aria-pressed="false" id="favBtn">★</span>
<script>
const fav = document.getElementById('favBtn');
fav.addEventListener('click', toggleFavorite);
fav.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault(); // Space should not scroll
fav.click();
}
});
function toggleFavorite(){
const pressed = fav.getAttribute('aria-pressed') === 'true';
fav.setAttribute('aria-pressed', String(!pressed));
// actual toggle logic...
}
</script>
Why: tabindex="0" makes it tabbable, keydown handles Enter/Space, and aria-pressed exposes state. Still: prefer <button> where possible.
3) Fix duplicated label/aria-label collisions
Wrong:
<label for="email">Email</label>
<input id="email" aria-label="Work email"> <!-- overrides visible label -->
Right:
<label for="email">Email</label>
<input id="email" /> <!-- visible label used as accessible name -->
Alternate valid pattern (add supplemental descriptor):
<label for="email">Email</label>
<input id="email" aria-describedby="emailHelp" />
<span id="emailHelp">We will not share your address.</span>
Why: aria-label and aria-labelledby change the accessible name computation. Use visible <label> when possible; use aria-describedby for extra, non-naming info.
4) Modal/dialog: hide background from AT and manage focus
Pattern (minimal):
<main id="mainContent">...page content...</main>
<button id="openDialog">Open</button>
<div id="dialog" role="dialog" aria-modal="true" aria-labelledby="dlgTitle" hidden>
<h2 id="dlgTitle">Confirm Delete</h2>
<p>Delete this item permanently?</p>
<button id="confirm">Delete</button>
<button id="close">Cancel</button>
</div>
<script>
const main = document.getElementById('mainContent');
const dialog = document.getElementById('dialog');
const open = document.getElementById('openDialog');
const close = document.getElementById('close');
open.addEventListener('click', () => {
main.setAttribute('aria-hidden', 'true'); // hide background from AT
dialog.removeAttribute('hidden');
dialog.querySelector('button').focus(); // move focus into dialog
});
close.addEventListener('click', () => {
dialog.hidden = true;
main.removeAttribute('aria-hidden'); // restore background
open.focus(); // return focus
});
// Note: implement focus trap and Escape handler in production
</script>
Why: aria-modal="true" + aria-hidden on the rest of the page reduces AT noise and concentrates interaction in the dialog; keep aria-labelledby for the dialog title. Do not leave visible focusable controls outside the modal accessible to screen readers while it is open.
5) Keep aria-expanded and the DOM state in sync
Wrong:
<button id="menuBtn">Menu</button>
<nav id="menu">…</nav>
Right:
<button id="menuBtn" aria-expanded="false" aria-controls="menu">Menu</button>
<nav id="menu" hidden>
<a href="/a">A</a>
</nav>
<script>
const btn = document.getElementById('menuBtn');
const menu = document.getElementById('menu');
btn.addEventListener('click', () => {
const expanded = btn.getAttribute('aria-expanded') === 'true';
btn.setAttribute('aria-expanded', String(!expanded));
menu.hidden = expanded;
});
</script>
Why: Syncing the boolean aria-expanded with actual show/hide ensures assistive tech reflects true state.
Accessible component patterns you can copy into your codebase
Below are stabler, copy-ready patterns that match WAI-ARIA Authoring Practices and modern assistive tech expectations. Each pattern prefers semantics first and ARIA only where needed .
| Component | Key attributes / actions | Minimal copy-paste snippet |
|---|---|---|
| Button (preferred) |
<button type="button">Label</button> — no ARIA needed |
Use native <button>. |
| Toggle (two-state) |
<button aria-pressed="false"> and toggle to "true"
|
Use aria-pressed on native button to expose state. |
| Disclosure / Accordion |
button[aria-expanded][aria-controls] + panel with hidden
|
See disclosure snippet below. |
| Modal/Dialog |
role="dialog" aria-modal="true" aria-labelledby + background aria-hidden
|
See modal snippet above. |
| Menu button |
button[aria-haspopup="true"][aria-expanded] + role="menu" and role="menuitem" inside |
Use WAI-ARIA APG menu-button pattern for keyboard management. |
Accessible Disclosure (accordion) — copyable:
<button id="q1" aria-expanded="false" aria-controls="a1">What is X?</button>
<div id="a1" hidden>
<p>Answer text...</p>
</div>
<script>
const btn = document.getElementById('q1');
const panel = document.getElementById('a1');
btn.addEventListener('click', ()=>{
const is = btn.getAttribute('aria-expanded') === 'true';
btn.setAttribute('aria-expanded', String(!is));
panel.hidden = is;
});
</script>
Menu Button pattern: use the APG examples as a reference when you need keyboard arrow behavior and active-descendant management — do not invent partial keyboard handling.
Practical application: a step-by-step remediation checklist
Use this protocol in your sprint-level remediation and QA workflow. Each step maps to tests you can run immediately.
-
Discovery + triage
- Run a fast automated scan (axe-core, Lighthouse, WAVE) to collect low-hanging fruit. Automation surfaces missing labels, contrast, and obvious ARIA misuse.
- Triage findings by user impact (interactive elements with missing names or keyboard traps = P0). Prioritize fixes that restore operability for keyboard/screen reader users.
-
Code remediation (developer checklist)
- Replace non-semantic interactive elements with native equivalents: prefer
<button>,<a href>,<input>/<select>,<fieldset>/<legend>for grouped inputs. - Remove redundant ARIA that duplicates native semantics (e.g.,
role="button"on<button>). - Ensure every interactive element has an accessible name (visible
<label>oraria-labelledby/aria-labelonly where appropriate). Verify using the accessible name computation rules. - Avoid
tabindex> 0; usetabindex="0"only when necessary; prefer DOM order. - When ARIA roles are required for custom widgets, implement the full keyboard model (APG patterns) and keep ARIA state attributes in sync with DOM state.
- Replace non-semantic interactive elements with native equivalents: prefer
-
Dev / CI automation
- Wire
@axe-core/cliinto CI for blocking checks on PRs for high-severity rules:
- Wire
# example: run axe-cli against local dev server and fail on violations
npx @axe-core/cli http://localhost:3000 --tags wcag2a,wcag2aa --exit
- Convert automated output to actionable tickets and attach minimal reproduction snippets (DOM + failing rule).
-
Manual QA / Assistive-technology verification (the essential step)
- NVDA (Windows): start NVDA, Tab through controls, listen for role + name + state. Use
NVDA+Tabto report the focused control andNVDA+bto read the active window content. Ensure Enter/Space activate the control. - VoiceOver (macOS/iOS): toggle with
Cmd+F5(macOS) or set VoiceOver in Settings (iOS). Use VO keys (Control+Option) to navigate; confirmbuttonannouncements and state changes. Use the VoiceOver rotor for quicker checks on headings/links. - TalkBack (Android): enable TalkBack in Settings > Accessibility and verify that gestures and spoken labels match visible labels; confirm touch targets are ≥48dp where possible.
- Inspect the browser Accessibility tree (DevTools → Accessibility pane) to confirm the Computed name and Role match expectations, and that
aria-*attributes are present and correctly updated. (This step connects the DOM to what AT users hear.) - For each fix, record a single-line acceptance criterion: e.g., "When focused, NVDA announces 'Save, button' and Enter toggles Save".
- NVDA (Windows): start NVDA, Tab through controls, listen for role + name + state. Use
-
Regression controls
- Add unit/integration tests where possible: use axe in Playwright or Cypress to scan important flows. Use a human-guided test matrix for screen reader combinations and key user journeys.
- Make accessibility part of code review checklists: require reviewers to confirm semantic HTML choices before accepting ARIA. Document patterns in your component library.
-
Audit log & measurement
- Track the number of critical AT failures pre/post remediation (e.g., missing labels, keyboard traps). WebAIM data shows pages with ARIA present often have more detectable errors; reducing broken ARIA usage reduces your detectable error rate and user-impact issues. Use those metrics to demonstrate progress.
Quick QA checklist (short):
- Visible label present for each form control or verified
aria-label/aria-labelledby.- No
aria-hidden="true"on focusable elements.- No
tabindex> 0 values.aria-expandedandaria-pressedreflect the runtime state.- Native elements used where possible; full ARIA contract implemented where required.
Every remediation should end with an assistive-technology smoke test (NVDA or VoiceOver) and a CI automated scan. Automated tooling reduces manual time spent on obvious errors; manual testing catches the context and state bugs automation cannot infer.
Ship the fixes that restore native semantics first, then harden custom widgets with the ARIA authoring-practice patterns. The result: fewer production support tickets, clearer a11y audit results, and measurable improvement in screen reader compatibility and WCAG compliance.
Sources:
Using ARIA in HTML (W3C) - Guidance on when to use ARIA versus native HTML; explains the rule "use native HTML when possible" and conformance notes.
ARIA: button role (MDN) - Practical notes and examples showing why native <button> is preferred over role="button".
ARIA: aria-hidden attribute (MDN) - Authoritative description of aria-hidden behavior and the warning not to use it on focusable elements.
WAI-ARIA Authoring Practices 1.2 (APG) (W3C) - Patterns and keyboard models for complex widgets (menu-button, disclosure, dialog, tabs, etc.).
The WebAIM Million (2023) - Large-scale analysis showing prevalence of ARIA attributes and correlation between ARIA usage and detected errors; useful for triage prioritization.
Accessible Name and Description Computation (AccName) (W3C) - Normative spec for how accessible names and descriptions are computed and why aria-label/aria-labelledby can override visible labels.
HTML tabindex global attribute (MDN) - Explanation of tabindex values, accessibility concerns, and why positive tabindex values should be avoided.
axe-core / Axe DevTools (Deque) - Engine and tooling guidance for automated accessibility testing and CI integration; used here to demonstrate automation capabilities and integration examples.
NVDA User Guide (NV Access) - Reference for NVDA commands and best practices for testing with NVDA.
Turn on and practice VoiceOver on iPhone (Apple Support) - Official VoiceOver guidance for iOS; general VoiceOver control and testing steps.
Android accessibility testing guidance (Android Open Source / docs) - Guidance on testing with TalkBack and Explore-by-Touch, and recommendations for audible prompts and gestures.
Top comments (0)