Developers often make the same mistake after learning ARIA. They understand the concept, but they are unsure when and how to apply it in real projects.

ARIA practical guide main visual with ARIA attributes highlighted in a code editor
ARIA practical guide main visual with ARIA attributes highlighted in a code editor
Cover image example: visual that symbolizes applying ARIA attributes · Generated by Nanobanana AI

You may have heard the phrase: “ARIA is a last resort.” Use semantic HTML first, and add ARIA only when native HTML is not enough. This guide follows that principle and shows how to use ARIA effectively in real work.

1. The Golden Rule of ARIA: When to Use It, When Not to

1.1 When You Should NOT Use ARIA

Many developers overuse ARIA. Start by checking cases where ARIA is unnecessary.

Diagram comparing ARIA mistakes and recommended practices - incorrect on the left, correct on the right
Diagram comparing ARIA mistakes and recommended practices - incorrect on the left, correct on the right
Before/after comparison of ARIA usage · Generated by Nanobanana AI
html
<!-- ❌ Bad: Unnecessary ARIA -->
<button role="button" aria-label="Click">Click</button>
<!-- <button> is already a button; role is redundant -->

<!-- ❌ Bad: ARIA used when semantic HTML exists -->
<div role="heading" aria-level="1">Title</div>
<!-- Use <h1>Title</h1> instead -->

<!-- ❌ Bad: aria-label when visible text already exists -->
<button aria-label="Save">
  <i class="icon-save"></i> Save
</button>
<!-- If visible text exists, aria-label is unnecessary -->

<!-- ✅ Good: No ARIA needed (semantic HTML is enough) -->
<button>Save</button>
<h1>Title</h1>
<form>
  <label for="email">Email</label>
  <input id="email" type="email">
</form>

Elements that are sufficient without ARIA:

  • Button: <button>
  • Link: <a>
  • Form: <form>, <input>, <textarea>, <select>
  • Headings: <h1> ~ <h6>
  • Navigation: <nav>
  • Main: <main>
  • Article: <article>
  • Section: <section>

1.2 When You SHOULD Use ARIA

ARIA is needed in cases like these:

  1. No semantic HTML exists: custom widgets or special UI
  2. Provide extra information: native HTML is not enough
  3. Announce state changes: dynamic updates for screen readers
html
<!-- ✅ Case 1: Custom widget -->
<div class="custom-tabs">
  <div role="tablist">
    <button role="tab" aria-selected="true" aria-controls="panel-1" tabindex="0">
      Tab 1
    </button>
    <button role="tab" aria-selected="false" aria-controls="panel-2" tabindex="-1">
      Tab 2
    </button>
  </div>
  <div id="panel-1" role="tabpanel">Content 1</div>
  <div id="panel-2" role="tabpanel" hidden>Content 2</div>
</div>

<!-- ✅ Case 2: Additional info -->
<button aria-label="Open chart">📊</button>
<!-- Icon-only button requires aria-label -->

<!-- ✅ Case 3: Announce state change -->
<span aria-live="polite" aria-atomic="true" id="notifications"></span>
<script>
  // Add message after user action
  document.getElementById('notifications').textContent = 'Saved.';
</script>

2. Practical ARIA Patterns

Diagram showing ARIA role hierarchy: widget roles, live region roles, and structure roles
Diagram showing ARIA role hierarchy: widget roles, live region roles, and structure roles
Hierarchy of ARIA role types · Generated by Nanobanana AI

2.1 Labeling: aria-label vs aria-labelledby

These attributes tell assistive technologies what an element is.

  • aria-label is best when you need a short, direct label (icon buttons).
  • aria-labelledby is best when you can reference existing visible text, keeping visual and spoken labels aligned.
html
<!-- 1. aria-label: simple label -->
<button aria-label="Close">×</button>

<!-- 2. aria-labelledby: reference another element -->
<h2 id="dialog-title">User Settings</h2>
<div role="dialog" aria-labelledby="dialog-title">
  <!-- Dialog content -->
</div>

<!-- 3. Combine labels from multiple elements -->
<span id="search-label">Product search</span>
<span id="search-hint">(name, category)</span>
<input
  aria-labelledby="search-label search-hint"
  placeholder="Search"
>

<!-- 4. Visually hidden label -->
<style>
  .sr-only {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip: rect(0, 0, 0, 0);
    border: 0;
  }
</style>

<button>
  <span class="sr-only">Back</span>
</button>

2.2 Descriptions and Hints: aria-describedby

aria-describedby links supplementary information. Use it for hints, error messages, and extra context that should be read after the main label. Clear separation between label and description keeps information structured.

html
<!-- Additional help text -->
<label for="password">Password</label>
<input
  id="password"
  type="password"
  aria-describedby="pwd-requirements pwd-strength"
>
<div id="pwd-requirements" class="hint">
  Minimum 8 chars, include uppercase, numbers, and symbols (@, #, $)
</div>
<div id="pwd-strength" aria-live="polite">
  Strength: Weak
</div>

<!-- Complex image description -->
<img
  src="chart.png"
  alt="Monthly sales"
  aria-describedby="chart-desc"
>
<p id="chart-desc">
  Sales from January to December 2024.
  Peak in July at 15,000 units, lowest in February at 5,000.
</p>

<!-- Error message -->
<label for="email">Email</label>
<input id="email" type="email" aria-describedby="email-error">
<span id="email-error" role="alert">
  Please enter a valid email address.
</span>

2.3 States: aria-checked, aria-pressed, aria-current

These attributes communicate current UI state to assistive technologies. The goal is to mirror what sighted users see (checked, pressed, current location, expanded). Native elements expose state automatically, so you usually manage these only for custom widgets.

html
<!-- 1. Checkbox/radio state -->
<label>
  <input type="checkbox">
  I agree to the terms
</label>

<!-- Native checkbox/radio already exposes state.
     Use aria-checked only for custom widgets. -->

<!-- 2. Toggle button state -->
<button
  aria-pressed="false"
  aria-label="Mute"
  id="mute-btn"
>
  🔊
</button>

<script>
  const muteBtn = document.getElementById('mute-btn');
  muteBtn.addEventListener('click', function() {
    const isPressed = this.getAttribute('aria-pressed') === 'true';
    this.setAttribute('aria-pressed', !isPressed);
    // Toggle mute logic
  });
</script>

<!-- 3. Current page in navigation -->
<nav>
  <a href="/products">Products</a>
  <a href="/blog" aria-current="page">Blog</a>
  <a href="/about">About</a>
</nav>

<!-- 4. Expand/collapse state -->
<button aria-expanded="false" aria-controls="submenu">
  Categories
</button>
<ul id="submenu" hidden>
  <li><a href="#">Submenu 1</a></li>
  <li><a href="#">Submenu 2</a></li>
</ul>

<script>
  document.querySelector('button').addEventListener('click', function() {
    const isExpanded = this.getAttribute('aria-expanded') === 'true';
    this.setAttribute('aria-expanded', !isExpanded);
    document.getElementById('submenu').hidden = isExpanded;
  });
</script>

Quick summary

  • aria-checked: expresses a selection/checked state. Use it for custom checkboxes, radios, and switches.
  • aria-pressed: expresses on/off for toggle buttons.
  • aria-current: indicates the current location, most often the current page in navigation.
  • aria-expanded: communicates expanded/collapsed state for accordions, dropdowns, trees, etc.

3. Live Regions: Announcing Dynamic Updates

Live regions inform screen reader users when content changes without a page refresh.

Diagram visualizing aria-live for real-time updates and dynamic alerts
Diagram visualizing aria-live for real-time updates and dynamic alerts
Live region announcement flow · Generated by Nanobanana AI

3.1 aria-live

aria-live marks regions that should be announced automatically when they change. It’s essential for dynamic updates such as save confirmations, errors, or live search results. Use it only where necessary to avoid interrupting users too often.

html
<!-- 1. aria-live="polite" (default) -->
<!-- Announces after the user finishes the current task -->
<div id="notifications" aria-live="polite">
  <!-- Messages appear here -->
</div>

<!-- 2. aria-live="assertive" -->
<!-- Announces immediately (use for urgent alerts only) -->
<div id="error-alert" aria-live="assertive">
  <!-- Immediate error message -->
</div>

<!-- 3. aria-atomic="true" -->
<!-- Announces the whole region, not just the changed part -->
<div aria-live="polite" aria-atomic="true" id="search-results">
  <p>Results: 23</p>
  <ul>
    <li>Item 1</li>
    <li>Item 2</li>
  </ul>
</div>

<!-- 4. aria-relevant="additions text" -->
<!-- Control which changes are announced -->
<div aria-live="polite" aria-relevant="additions text">
  <!-- Only additions and text changes announced -->
</div>

Tip: Use aria-live="assertive" only for urgent alerts (errors, security warnings). For most status updates, keep it polite to avoid interrupting users.

Live search updates are obvious visually, but screen reader users won’t know results changed unless you announce them. Using role="status" or aria-live ensures they receive the same feedback.

Example showing aria-live announcing search results in a live search UI
Example showing aria-live announcing search results in a live search UI
Live search announcement example · Generated by Nanobanana AI
html
<div class="search-container">
  <input
    id="search-input"
    type="text"
    placeholder="Search..."
    aria-describedby="search-help"
  >
  <span id="search-help" class="sr-only">
    Results update as you type.
  </span>
</div>

<!-- Live region for search results -->
<div
  id="search-results"
  role="status"
>
  <!-- Results appear here -->
</div>

<script>
  const input = document.getElementById('search-input');
  const resultsDiv = document.getElementById('search-results');

  input.addEventListener('input', function(e) {
    const query = e.target.value.trim();

    if (!query) {
      resultsDiv.innerHTML = '';
      return;
    }

    // API request (example)
    fetch(`/api/search?q=${query}`)
      .then(res => res.json())
      .then(data => {
        resultsDiv.innerHTML = `
          <p>
            Found ${data.count} results.
          </p>
          <ul>
            ${data.items.map(item => `<li>${item.name}</li>`).join('')}
          </ul>
        `;
      });
  });
</script>

4. Custom Widget Implementations

Use ARIA when semantic HTML alone cannot cover complex UI.

Diagram showing custom widget patterns: tabs, accordion, dropdown, modal with ARIA
Diagram showing custom widget patterns: tabs, accordion, dropdown, modal with ARIA
Summary of widget patterns (tabs/accordion/dropdown/modal) · Generated by Nanobanana AI

4.1 Tabs

Tabs are a classic custom widget. The tablist/tab/tabpanel roles define structure, and keyboard rules (arrow keys, Home/End) are required for full accessibility.

Diagram explaining ARIA structure and keyboard navigation for a tabs widget
Diagram explaining ARIA structure and keyboard navigation for a tabs widget
Tabs widget: role/aria wiring and keyboard flow · Generated by Nanobanana AI
html
<div class="tabs">
  <div role="tablist">
    <button
      role="tab"
      aria-selected="true"
      aria-controls="panel-1"
      tabindex="0"
      id="tab-1"
    >
      Basic Info
    </button>
    <button
      role="tab"
      aria-selected="false"
      aria-controls="panel-2"
      tabindex="-1"
      id="tab-2"
    >
      Details
    </button>
  </div>

  <div id="panel-1" role="tabpanel" aria-labelledby="tab-1">
    Basic info content
  </div>
  <div id="panel-2" role="tabpanel" aria-labelledby="tab-2" hidden>
    Detailed content
  </div>
</div>

<style>
  [role="tab"][aria-selected="false"] {
    opacity: 0.6;
  }
</style>

<script>
  const tabs = document.querySelectorAll('[role="tab"]');
  const panels = document.querySelectorAll('[role="tabpanel"]');

  tabs.forEach(tab => {
    tab.addEventListener('click', function() {
      // Deactivate all tabs
      tabs.forEach(t => {
        t.setAttribute('aria-selected', 'false');
        t.setAttribute('tabindex', '-1');
      });
      panels.forEach(p => p.hidden = true);

      // Activate clicked tab
      this.setAttribute('aria-selected', 'true');
      this.setAttribute('tabindex', '0');
      const panelId = this.getAttribute('aria-controls');
      document.getElementById(panelId).hidden = false;
    });

    // Keyboard navigation
    tab.addEventListener('keydown', function(e) {
      let nextTab;
      if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
        nextTab = this.nextElementSibling || tabs[0];
      } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
        nextTab = this.previousElementSibling || tabs[tabs.length - 1];
      } else if (e.key === 'Home') {
        nextTab = tabs[0];
      } else if (e.key === 'End') {
        nextTab = tabs[tabs.length - 1];
      }

      if (nextTab) {
        e.preventDefault();
        nextTab.click();
        nextTab.setAttribute('tabindex', '0');
        this.setAttribute('tabindex', '-1');
        nextTab.focus();
      }
    });
  });
</script>

4.2 Accordion

Accordions rely on clear expanded/collapsed state. Expose it with aria-expanded, and link headers to panels with role="region" and aria-labelledby so screen readers understand the structure.

html
<div class="accordion">
  <div class="accordion-item">
    <button
      aria-expanded="false"
      aria-controls="section-1"
      id="accordion-1"
      class="accordion-btn"
    >
      FAQ 1
    </button>
    <div
      id="section-1"
      role="region"
      aria-labelledby="accordion-1"
      hidden
      class="accordion-content"
    >
      <p>Answer 1</p>
    </div>
  </div>

  <div class="accordion-item">
    <button
      aria-expanded="false"
      aria-controls="section-2"
      id="accordion-2"
      class="accordion-btn"
    >
      FAQ 2
    </button>
    <div
      id="section-2"
      role="region"
      aria-labelledby="accordion-2"
      hidden
      class="accordion-content"
    >
      <p>Answer 2</p>
    </div>
  </div>
</div>

<style>
  .accordion-content {
    max-height: 0;
    overflow: hidden;
    transition: max-height 0.3s ease;
  }

  .accordion-content:not([hidden]) {
    max-height: 500px;
  }

  button[aria-expanded="true"]::after {
    content: '▼';
  }

  button[aria-expanded="false"]::after {
    content: '▶';
  }
</style>

<script>
  document.querySelectorAll('.accordion-btn').forEach(btn => {
    btn.addEventListener('click', function() {
      const isExpanded = this.getAttribute('aria-expanded') === 'true';
      this.setAttribute('aria-expanded', !isExpanded);
      document.getElementById(this.getAttribute('aria-controls')).hidden = isExpanded;
    });
  });
</script>

4.3 Dropdown Menu

Dropdowns are typically a toggle button plus a list. For most navigation use cases, a simple button + ul is the safest pattern. Use complex menu roles only when the interaction truly matches application menus.

html
<div class="dropdown">
  <button
    id="menu-btn"
    aria-expanded="false"
    aria-controls="menu-list"
  >
    Menu
  </button>

  <ul id="menu-list" hidden aria-labelledby="menu-btn">
    <li><a href="#">Option 1</a></li>
    <li><a href="#">Option 2</a></li>
    <li><a href="#">Option 3</a></li>
  </ul>
</div>

<script>
  const btn = document.getElementById('menu-btn');
  const menu = document.getElementById('menu-list');
  const items = menu.querySelectorAll('a');

  btn.addEventListener('click', () => {
    const isExpanded = btn.getAttribute('aria-expanded') === 'true';
    btn.setAttribute('aria-expanded', !isExpanded);
    menu.hidden = isExpanded;

    if (!isExpanded) {
      items[0].focus();
    }
  });

  // Close on Escape
  menu.addEventListener('keydown', (e) => {
    if (e.key === 'Escape') {
      btn.click();
      btn.focus();
    }
  });

  // Close on outside click
  document.addEventListener('click', (e) => {
    if (!e.target.closest('.dropdown')) {
      menu.hidden = true;
      btn.setAttribute('aria-expanded', 'false');
    }
  });
</script>

5. Modal/Dialog Pattern

Modals are all about focus management. Move focus into the dialog on open, and restore it on close. aria-modal="true" tells assistive tech that the dialog is the active interaction context.

Diagram showing focus management in a modal dialog, including focus movement and aria-modal
Diagram showing focus management in a modal dialog, including focus movement and aria-modal
Modal focus management flow (open/close/restore) · Generated by Nanobanana AI
html
<button id="open-modal">Open modal</button>

<div
  id="modal"
  role="dialog"
  aria-labelledby="modal-title"
  aria-modal="true"
  hidden
>
  <div class="modal-content">
    <h2 id="modal-title">Important Info</h2>

    <p>This is modal content.</p>

    <button id="close-modal">Close</button>
  </div>
</div>

<style>
  #modal:not([hidden]) {
    display: flex;
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    background: rgba(0, 0, 0, 0.5);
  }

  .modal-content {
    margin: auto;
    background: white;
    padding: 2rem;
    border-radius: 8px;
  }
</style>

<script>
  const modal = document.getElementById('modal');
  const openBtn = document.getElementById('open-modal');
  const closeBtn = document.getElementById('close-modal');
  let previousFocus = null;

  openBtn.addEventListener('click', () => {
    previousFocus = document.activeElement;
    modal.hidden = false;
    // Inert everything except the modal
    const siblings = Array.from(document.body.children).filter(el => el !== modal);
    siblings.forEach(el => (el.inert = true));

    // Move focus into the modal
    const focusableElements = modal.querySelectorAll(
      'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
    );
    focusableElements[0]?.focus();

    // Focus trap inside modal
    focusableElements[focusableElements.length - 1].addEventListener('keydown', (e) => {
      if (e.key === 'Tab' && !e.shiftKey) {
        e.preventDefault();
        focusableElements[0].focus();
      }
    });

    focusableElements[0].addEventListener('keydown', (e) => {
      if (e.key === 'Tab' && e.shiftKey) {
        e.preventDefault();
        focusableElements[focusableElements.length - 1].focus();
      }
    });
  });

  closeBtn.addEventListener('click', () => {
    modal.hidden = true;
    const siblings = Array.from(document.body.children).filter(el => el !== modal);
    siblings.forEach(el => (el.inert = false));
    previousFocus?.focus();
  });

  // Close on Escape
  document.addEventListener('keydown', (e) => {
    if (e.key === 'Escape' && !modal.hidden) {
      closeBtn.click();
    }
  });
</script>

6. ARIA Validation

6.1 Common ARIA Mistakes

ARIA is powerful, but misuse can reduce accessibility. These are the most common real-world pitfalls to watch for during implementation and review.

html
<!-- ❌ Mistake 1: Misusing roles -->
<div role="button" onclick="alert('Click')">Click me</div>
<!-- Issue: no keyboard focus, no keyboard activation -->

<!-- ✅ Correct -->
<button onclick="alert('Click')">Click me</button>

<!-- ❌ Mistake 2: aria-label without a visible label -->
<input aria-label="Search" placeholder="Search">
<!-- Issue: visible users see no label, assistive tech gets a label -->

<!-- ✅ Correct -->
<label for="search">Search</label>
<input id="search" placeholder="Search">

<!-- ❌ Mistake 3: Overusing live regions -->
<div aria-live="assertive" id="log">
  Log messages...
</div>
<!-- Issue: announces everything, can be noisy -->

<!-- ✅ Correct -->
<div aria-live="polite" aria-atomic="false" id="notification">
  <!-- Only latest message -->
</div>

<!-- ❌ Mistake 4: Inconsistent tabindex in tabs -->
<div role="tablist">
  <div role="tab" tabindex="0">First</div>
  <div role="tab">Second</div> <!-- missing tabindex -->
  <div role="tab" tabindex="0">Third</div>
</div>
<!-- Issue: inconsistent focus order -->

<!-- ✅ Correct: only first tab is 0, others are -1 -->
<div role="tablist">
  <div role="tab" tabindex="0">First</div>
  <div role="tab" tabindex="-1">Second</div>
  <div role="tab" tabindex="-1">Third</div>
</div>

6.2 ARIA Validation Checklist

When to use ARIA

  • Is semantic HTML insufficient?
  • Is this a custom widget?
  • Is this information important for screen readers?

When using ARIA

  • Did you set role, aria-label/labelledby, aria-describedby?
  • Does keyboard navigation work?
  • Do aria-* states reflect changes?
  • Is focus managed properly? (e.g., on modal open)
  • Are live regions used sparingly?

Testing

  • Screen readers (NVDA, JAWS, VoiceOver)
  • Keyboard-only navigation
  • axe DevTools, Lighthouse
  • Compare to WAI-ARIA Authoring Practices

7. ARIA Tips and Best Practices

Guide summarizing keyboard navigation patterns for custom widgets such as tabs, menus, accordion, and dropdown
Guide summarizing keyboard navigation patterns for custom widgets such as tabs, menus, accordion, and dropdown
Keyboard shortcuts by widget type · Generated by Nanobanana AI

7.1 Role Tips

Roles add meaning to elements that lack semantics. Avoid duplicating roles on native elements; use roles only when needed.

html
<!-- 1. Avoid duplicating roles on native elements -->
<button>Good</button>
<div role="button">Use only when needed</div>

<!-- 2. Prefer changing elements over changing roles -->
<!-- ❌ Not good -->
<div id="button" role="button">Click</div>
<script>
  document.getElementById('button').setAttribute('role', 'link');
</script>

<!-- ✅ Good -->
<button>Click</button>

<!-- 3. Explicitly define composite roles -->
<div role="tablist">
  <div role="tab">Tab 1</div>
  <div role="tabpanel">Content 1</div>
</div>

7.2 ARIA and CSS

Styling based on ARIA state keeps visual feedback aligned with accessibility state. For example, changing color when aria-pressed="true" makes the UI consistent for everyone.

css
/* Style based on ARIA state */
button[aria-pressed="true"] {
  background-color: #4A90E2;
  color: white;
}

button[aria-pressed="false"] {
  background-color: #f0f0f0;
  color: #333;
}

/* Focus indicators */
[role="button"]:focus {
  outline: 3px solid #4A90E2;
  outline-offset: 2px;
}

/* Optional: visualize live regions */
[aria-live="polite"] {
  border-left: 3px solid #27AE60;
  padding-left: 0.5rem;
}

/* Hidden panels */
[role="tabpanel"][hidden] {
  display: none;
}

7.3 Performance Considerations

Live regions can be noisy and expensive if overused. Batch updates where possible and keep only the latest status to reduce cognitive and performance overhead.

javascript
// ❌ Bad: append each update individually
const notifications = document.getElementById('notifications');
for (let i = 0; i < 1000; i++) {
  notifications.innerHTML += `<p>Notification ${i}</p>`;
}

// ✅ Good: batch updates
const notifications = document.getElementById('notifications');
const fragment = document.createDocumentFragment();
for (let i = 0; i < 1000; i++) {
  const p = document.createElement('p');
  p.textContent = `Notification ${i}`;
  fragment.appendChild(p);
}
notifications.appendChild(fragment);

// ✅ Good: keep only the latest message
const notification = document.getElementById('notification');
function showNotification(message) {
  notification.innerHTML = message;
  setTimeout(() => {
    notification.innerHTML = '';
  }, 5000);
}

8. ARIA in Real Projects

8.1 Libraries and Frameworks

Many modern frameworks include ARIA support:

javascript
// React example
function Tabs() {
  const [selected, setSelected] = useState(0);

  return (
    <div role="tablist">
      {tabs.map((tab, i) => (
        <button
          key={i}
          role="tab"
          aria-selected={i === selected}
          aria-controls={`panel-${i}`}
          onClick={() => setSelected(i)}
        >
          {tab.label}
        </button>
      ))}
      {tabs.map((tab, i) => (
        <div
          key={i}
          id={`panel-${i}`}
          role="tabpanel"
          aria-labelledby={`tab-${i}`}
          hidden={i !== selected}
        >
          {tab.content}
        </div>
      ))}
    </div>
  );
}

8.2 ARIA Pattern Library

The WAI-ARIA Authoring Practices is the official pattern library. Use proven patterns instead of reinventing the wheel:

  • Buttons, links
  • Form inputs
  • Navigation
  • Tabs, accordions, menus
  • Dialogs, alerts
  • Grids, tables

Conclusion

ARIA is powerful, but it must be used responsibly. Remember:

  1. Use semantic HTML first: leverage native elements
  2. Add ARIA only when needed: keep it minimal
  3. Keyboard navigation is required: everything must work without a mouse
  4. Test with screen readers: verify real experience
  5. Manage focus and state: communicate dynamic changes

Accessible interfaces improve the experience for everyone. When you use ARIA correctly, both assistive tech users and all other users benefit.