woman on black folding wheelchair
Photo by Judita Mikalkevičė on Pexels.com

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, overusing role="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
    1. Start by meeting requirements with native elements.
    2. Only when lacking, add the smallest possible ARIA.
    3. 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 use ul/li with a/button).
  • Giving decorative icons verbose aria-labels (creates noise; mark decoration with aria-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 via aria-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 via aria-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, or role="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-labels 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 or role="status".

7. Screen-reader map (headings & landmarks) + dynamic updates (live regions)

  • Landmarks: use header / nav / main / aside / footer appropriately. Distinguish multiple navs with labels like aria-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 use role="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

  1. Tab through header → nav → main → footer in a logical order.
  2. Focus is visible: :focus-visible rings clearly stand out.
  3. Tabs/accordion: arrow navigation works; aria-selected / aria-expanded update correctly.
  4. Dialog: open → focus on title → trap works → Esc closes → focus returns to trigger.
  5. Combobox: arrow through options; aria-activedescendant updates; Enter commits.
  6. 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 have aria-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"; reserve role="alert" for urgent cases.

14. Wrap-up: ARIA is the last spoonful—the base is HTML and design

  1. Native-first + minimal ARIA yields stability, compatibility, and maintainability together.
  2. Keep Name/Role/Value aligned, and convey state changes via aria-*.
  3. Honor APG key behaviors (tabs = arrows, dialogs = trap, appropriate menu usage).
  4. Avoid anti-patterns (misusing aria-hidden, overusing role="menu", positive tabindex).
  5. 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.

By greeden

Leave a Reply

Your email address will not be published. Required fields are marked *

日本語が含まれない投稿は無視されますのでご注意ください。(スパム対策)