ARIA Practical Guide: Building Robust UIs with Minimal ARIA — APG-Compliant Component Design and Pitfalls
Executive summary (key points first)
- Native HTML first + minimal ARIA is the shortest path to UIs that don’t break for screen readers, keyboard, and voice users.
- Correctly exposing Name, Role, and Value is the core principle for assistive-technology compatibility.
- Follow APG (ARIA Authoring Practices) behaviors: tabs = arrow navigation, dialogs = focus trap, menus are generally unnecessary on websites, etc.
- Avoid “don’t-do” ARIA patterns (misusing
aria-hidden
, overusingrole="menu"
, sprinkling too many “polite” roles).- Includes copy-pasteable code templates, test angles, and checklists you can use in production (tabs, accordion, dialog, toggle button, combobox).
Intended audience (concrete): Front-end engineers, UI/UX designers, design-system owners, QA/accessibility engineers, PMs
Accessibility target: WCAG 2.1 AA baseline (AAA where feasible).
Who benefits? Screen-reader users, keyboard-first users, voice-input users, people with diverse cognitive/learning needs, people with diverse vision/color perception, and mobile users in general.
1. First, the “minimal ARIA” mindset: exploit what HTML can already do
ARIA isn’t magic; it’s the finishing seasoning. Elements like <button>
, <a href>
, <label>
, <fieldset>
, <table>
already come with semantics and keyboard behavior.
- Principles
- Start by meeting requirements with native elements.
- Only when lacking, add the smallest possible ARIA.
- Implement behavior together with semantics (keyboard, focus).
- Common misconception: “Adding ARIA roles everywhere makes things better.” → ❌ Unnecessary roles can break AT interpretation.
Classic don’ts
- Putting
aria-hidden="true"
on a visible, operable control (it disappears from AT). - Using
role="menu"
for site nav (intended for application menus; for global web nav useul/li
witha
/button
). - Giving decorative icons verbose
aria-label
s (creates noise; mark decoration witharia-hidden="true"
).
2. Line up Name / Role / Value: the three things AT needs
2.1 Name (Accessible Name)
- Visible text = the name by default. In
<button>Save</button>
, “Save” is the name. - Icon buttons need visible text or an
aria-label
. Make sure it doesn’t conflict with visible text (WCAG 2.5.3).
<button aria-label="Search">
<svg aria-hidden="true" focusable="false">…</svg>
</button>
2.2 Role
- Don’t override roles that native elements already provide.
- Only when needed, add roles like
role="tab"
,role="dialog"
,role="switch"
—and keep it minimal.
2.3 Value (State/Property)
- Expose state via ARIA attributes:
aria-expanded
(open/closed),aria-selected
(selected),aria-pressed
(toggle),aria-current
(current page), etc.
<a href="/pricing" aria-current="page">Pricing</a>
<button aria-pressed="true">Favorite</button>
3. APG-compliant key patterns: roving Tab, activedescendant, label associations
3.1 Roving tabindex
(tabs, toolbars, etc.)
- Only the active item gets
tabindex="0"
, others get-1
. - Arrow keys move selection;
Enter/Space
activates.
<div role="tablist" aria-label="Settings">
<button role="tab" aria-selected="true" tabindex="0" id="t1" aria-controls="p1">General</button>
<button role="tab" aria-selected="false" tabindex="-1" id="t2" aria-controls="p2">Notifications</button>
</div>
<section id="p1" role="tabpanel" aria-labelledby="t1">…</section>
<section id="p2" role="tabpanel" aria-labelledby="t2" hidden>…</section>
3.2 aria-activedescendant
(autocomplete/virtual lists)
- Keep focus on the input while announcing the active option’s ID.
<input id="city" role="combobox" aria-expanded="true" aria-controls="list" aria-activedescendant="opt-2">
<ul id="list" role="listbox">
<li role="option" id="opt-1">Tokyo</li>
<li role="option" id="opt-2" aria-selected="true">Toronto</li>
</ul>
3.3 Label associations (aria-labelledby
/ aria-describedby
)
- Reliably tie headings/descriptions to components.
<div role="dialog" aria-modal="true" aria-labelledby="dlg-title" aria-describedby="dlg-desc">
<h2 id="dlg-title">Terms of Use</h2>
<p id="dlg-desc">Key points and a request for consent</p>
</div>
4. Component recipes with minimal ARIA (copy-paste ready)
4.1 Accordion (Disclosure)
- Make the toggle a real
button
. - Reflect state with
aria-expanded
; tie the panel viaaria-controls
+hidden
.
<h3>
<button aria-expanded="false" aria-controls="faq1" id="q1">Shipping</button>
</h3>
<div id="faq1" role="region" aria-labelledby="q1" hidden>
<p>Delivery usually takes 2–3 business days.</p>
</div>
<script>
const btn = document.getElementById('q1');
const panel = document.getElementById('faq1');
btn.addEventListener('click', () => {
const open = btn.getAttribute('aria-expanded') === 'true';
btn.setAttribute('aria-expanded', String(!open));
panel.hidden = open;
});
</script>
4.2 Tabs (Tab / Tabpanel)
- Use roving
tabindex
+aria-selected
to convey selection. - Hide inactive panels with
hidden
to remove them from Tab order.
const tabs = [...document.querySelectorAll('[role="tab"]')];
function activate(i, focus = true){
tabs.forEach((t, idx) => {
const selected = idx === i;
t.setAttribute('aria-selected', selected);
t.tabIndex = selected ? 0 : -1;
document.getElementById(t.getAttribute('aria-controls')).hidden = !selected;
});
if (focus) tabs[i].focus();
}
tabs.forEach((t, i) => {
t.addEventListener('click', () => activate(i, false));
t.addEventListener('keydown', e => {
const dir = (e.key === 'ArrowRight') - (e.key === 'ArrowLeft');
if (dir){ e.preventDefault(); activate((i + dir + tabs.length) % tabs.length); }
if (e.key === 'Home'){ e.preventDefault(); activate(0); }
if (e.key === 'End'){ e.preventDefault(); activate(tabs.length - 1); }
});
});
4.3 Dialog (Modal)
role="dialog"
+aria-modal="true"
with a title viaaria-labelledby
.- Implement a focus trap, close on
Esc
, and return focus to the trigger on close.
<button id="open">Terms of Use</button>
<div id="dlg" role="dialog" aria-modal="true" aria-labelledby="ttl" hidden>
<h2 id="ttl">Terms of Use</h2>
<p>…key points…</p>
<button id="agree">Agree</button>
<button id="close">Close</button>
</div>
<script>
const open = document.getElementById('open');
const dlg = document.getElementById('dlg');
const close = document.getElementById('close');
let last;
function trap(e){
if(e.key === 'Escape'){ hide(); return; }
if(e.key !== 'Tab') return;
const f = [...dlg.querySelectorAll('a,button,[tabindex="0"]')];
if(!f.length) return;
const first = f[0], lastF = f[f.length - 1];
if(e.shiftKey && document.activeElement === first){ e.preventDefault(); lastF.focus(); }
else if(!e.shiftKey && document.activeElement === lastF){ e.preventDefault(); first.focus(); }
}
function show(){ last = document.activeElement; dlg.hidden = false; dlg.querySelector('button')?.focus(); document.addEventListener('keydown', trap); }
function hide(){ dlg.hidden = true; document.removeEventListener('keydown', trap); last?.focus(); }
open.addEventListener('click', show);
close.addEventListener('click', hide);
</script>
4.4 Toggle button (aria-pressed
) / Switch
- Use
aria-pressed
for toggle buttons, orrole="switch"
+aria-checked
.
<button class="fav" aria-pressed="false">Favorite</button>
<script>
const b = document.querySelector('.fav');
b.addEventListener('click', () => {
b.setAttribute('aria-pressed', String(b.getAttribute('aria-pressed') !== 'true'));
});
</script>
4.5 Combobox (minimal search suggest)
role="combobox"
+aria-controls
+aria-expanded
+aria-activedescendant
.- Options use
role="listbox"
/role="option"
.
<label for="kw">Keyword</label>
<input id="kw" role="combobox" aria-expanded="false" aria-controls="kw-list" autocomplete="off">
<ul id="kw-list" role="listbox" hidden></ul>
<script>
const inp = document.getElementById('kw');
const list = document.getElementById('kw-list');
const data = ['JavaScript','Java','Ruby','Rust','Python'];
inp.addEventListener('input', () => {
const q = inp.value.toLowerCase();
const items = data.filter(x => x.toLowerCase().startsWith(q)).slice(0, 5);
list.innerHTML = items.map((t,i) => `<li role="option" id="opt-${i}">${t}</li>`).join('');
const has = items.length > 0;
inp.setAttribute('aria-expanded', String(has));
list.hidden = !has;
if(has) inp.setAttribute('aria-activedescendant', 'opt-0');
else inp.removeAttribute('aria-activedescendant');
});
list.addEventListener('click', e => {
if(e.target.role !== 'option') return;
inp.value = e.target.textContent;
list.hidden = true;
inp.setAttribute('aria-expanded', 'false');
});
</script>
5. “Don’t-do” ARIA and why (anti-pattern quick table)
Anti-pattern | Why it’s a problem | Correct alternative |
---|---|---|
Styling headings with <div role="heading" aria-level="2"> everywhere |
Breaks the document’s semantic outline | Use native headings like <h2> and style with CSS |
Using role="menu" for global nav |
Web site nav ≠ app menus; users expect arrow key behavior etc. | nav + ul/li + a /button (Disclosure if needed) |
aria-hidden="true" on visible buttons |
Removes the control from AT → inoperable | Never put aria-hidden on operable elements |
Long aria-label s on every <svg> |
Information overload; decorative icons get read | Mark decoration aria-hidden="true" ; provide info via <title> or parent label |
Positive tabindex values (e.g., tabindex="99" ) |
Breaks focus order; unmaintainable | Use 0 or -1 ; design for DOM order |
Overusing role="alert" |
Noisy; interrupts for non-critical updates | Prefer role="status" for routine updates |
6. Accessible-name pitfalls: does it match the visible text?
- Label in Name (2.5.3): Voice users say the visible text. If the accessible name differs (“Search” vs. “Find”), commands may fail.
<!-- Visible: “Search” / Name also “Search” -->
<button aria-label="Search">Search</button>
- Composite labels: With icon + text, put text first for natural SR output.
- Dynamic wording: For “Save → Saving…”, use a short message in an
aria-live="polite"
region orrole="status"
.
7. Screen-reader map (headings & landmarks) + dynamic updates (live regions)
- Landmarks: use
header
/nav
/main
/aside
/footer
appropriately. Distinguish multiplenav
s with labels likearia-label="Footer navigation"
. - Headings: Exactly one
h1
; nest levels per section structure. - Live regions: Use sparingly. Form errors can use
role="alert"
; “Saved” notices can userole="status"
.
<div id="msg" role="status" aria-atomic="true" class="sr-only"></div>
<script> msg.textContent = 'Draft saved.'; </script>
8. Baking it into your design system: specify Name/Role/Value & key ops
Every component doc should include:
- Name (accessible name): visible text /
aria-label
/aria-labelledby
. - Role: native vs. explicit.
- State (value):
aria-expanded
/aria-selected
/aria-pressed
/aria-current
, etc. - Key operations: Tab/Shift+Tab, arrows, Enter/Space, Esc, Home/End.
- Focus management: initial focus, trap behavior, and return target.
- Contrast: focus rings, icons, and non-text visuals at least 3:1.
9. Five-minute manual smoke test
- Tab through header → nav → main → footer in a logical order.
- Focus is visible:
:focus-visible
rings clearly stand out. - Tabs/accordion: arrow navigation works;
aria-selected
/aria-expanded
update correctly. - Dialog: open → focus on title → trap works →
Esc
closes → focus returns to trigger. - Combobox: arrow through options;
aria-activedescendant
updates; Enter commits. - SR check (NVDA/VoiceOver/TalkBack): headings list, landmark navigation, and form labels read correctly.
10. Case study: rescuing a <div>
-based menu
Before
<div class="menu">
<div class="item" onclick="go('/pricing')">Pricing</div>
<div class="item" onclick="go('/docs')">Docs</div>
</div>
- No role, no keyboard, not read by SR.
After (minimal ARIA + native-first)
<nav aria-label="Primary navigation">
<ul>
<li><a href="/pricing">Pricing</a></li>
<li><a href="/docs">Documentation</a></li>
</ul>
</nav>
- It “just works”: link roles, Tab navigation, and SR output come for free. That’s the power of minimal ARIA.
11. Who gains what? (concrete impacts)
- Front-end engineers: Fewer event-wiring and custom-role bugs; stronger against regressions.
- UI/UX designers: Key-ops and labeling rules are explicit, improving design consistency.
- QA / accessibility engineers: Tests standardize around Name/Role/Value + APG, reducing judgment variance.
- PMs / business: Solid compliance foundation improves brand trust and operational cost.
- Users (AT, keyboard, voice): Clearer operation with less fatigue and fewer errors; lower cognitive load.
12. Quick checklist (paste into your issue template)
- [ ] Use native elements first; add ARIA minimally only where needed.
- [ ] No
aria-hidden
on operable/active controls. - [ ] Name/Role/Value always exposed (labels = visible text; correct roles; state attributes).
- [ ] Tabs/toolbars use roving
tabindex
; dialogs havearia-modal
+ trap + return. - [ ] Combobox uses either
aria-activedescendant
or real focus moves consistently. - [ ] No
role="menu"
for global site navigation. - [ ] Focus rings are always visible; non-text contrast ≥ 3:1.
- [ ] SR map works: headings, landmarks, and form labels.
- [ ] Live updates default to
role="status"
; reserverole="alert"
for urgent cases.
14. Wrap-up: ARIA is the last spoonful—the base is HTML and design
- Native-first + minimal ARIA yields stability, compatibility, and maintainability together.
- Keep Name/Role/Value aligned, and convey state changes via
aria-*
. - Honor APG key behaviors (tabs = arrows, dialogs = trap, appropriate menu usage).
- Avoid anti-patterns (misusing
aria-hidden
, overusingrole="menu"
, positivetabindex
). - Document Name/Role/Value & key ops in your design system and run the five-minute smoke as a routine.
ARIA is the finishing spoonful of seasoning. The better your HTML and information architecture, the more people you’ll reach with less ARIA. Here’s to your UI quietly, correctly communicating—starting today. I’m cheering you on.