The first rule of ARIA

"No ARIA is better than bad ARIA."

β€” W3C WAI-ARIA Best Practices

Before reaching for ARIA, ask:

  1. Can I use a native HTML element? Native elements have built-in accessibility.
  2. Can I style a native element? A styled <button> beats a <div role="button">.
  3. Do I really need this custom widget? Simpler is often better.

Use ARIA when HTML alone can't express the semantics you needβ€”but use it correctly.

Essential ARIA attributes

Labeling

AttributePurposeExample
aria-label Provides accessible name when no visible label <button aria-label="Close">Γ—</button>
aria-labelledby Points to element(s) that label this element <div aria-labelledby="heading1">
aria-describedby Points to element with additional description <input aria-describedby="hint1">

State & properties

AttributePurposeValues
aria-expanded Indicates expandable element state true / false
aria-selected Indicates selection state true / false
aria-checked Checkbox/toggle state true / false / mixed
aria-pressed Toggle button state true / false
aria-hidden Hides from assistive tech (not visual) true / false
aria-live Announces dynamic content changes polite / assertive / off

Pattern: Button

First choice: Use <button> element.

If you must use a div/span:

<div role="button" 
     tabindex="0"
     onclick="handleClick()"
     onkeydown="handleKeyDown(event)">
  Click me
</div>

<script>
function handleKeyDown(event) {
  // Buttons respond to Enter and Space
  if (event.key === 'Enter' || event.key === ' ') {
    event.preventDefault();
    handleClick();
  }
}
</script>

Requirements checklist

Pattern: Modal dialog

HTML structure

<div role="dialog" 
     aria-modal="true"
     aria-labelledby="dialog-title"
     aria-describedby="dialog-desc">
  <h2 id="dialog-title">Confirm deletion</h2>
  <p id="dialog-desc">This action cannot be undone.</p>
  <button>Delete</button>
  <button>Cancel</button>
</div>

Keyboard behavior

JavaScript requirements

function openModal(modal, triggerButton) {
  // 1. Show modal
  modal.style.display = 'block';
  
  // 2. Store trigger for later
  modal.triggerElement = triggerButton;
  
  // 3. Move focus to first focusable element
  const firstFocusable = modal.querySelector('button, [href], input');
  firstFocusable.focus();
  
  // 4. Trap focus
  modal.addEventListener('keydown', trapFocus);
}

function closeModal(modal) {
  modal.style.display = 'none';
  modal.triggerElement.focus(); // Return focus
}

function trapFocus(event) {
  if (event.key !== 'Tab') return;
  
  const focusables = modal.querySelectorAll('button, [href], input');
  const first = focusables[0];
  const last = focusables[focusables.length - 1];
  
  if (event.shiftKey && document.activeElement === first) {
    event.preventDefault();
    last.focus();
  } else if (!event.shiftKey && document.activeElement === last) {
    event.preventDefault();
    first.focus();
  }
}

Pattern: Tabs

HTML structure

<div class="tabs">
  <div role="tablist" aria-label="Course sections">
    <button role="tab" 
            id="tab-1" 
            aria-selected="true" 
            aria-controls="panel-1">
      Overview
    </button>
    <button role="tab" 
            id="tab-2" 
            aria-selected="false" 
            aria-controls="panel-2"
            tabindex="-1">
      Schedule
    </button>
    <button role="tab" 
            id="tab-3" 
            aria-selected="false" 
            aria-controls="panel-3"
            tabindex="-1">
      Resources
    </button>
  </div>
  
  <div role="tabpanel" id="panel-1" aria-labelledby="tab-1">
    Overview content...
  </div>
  <div role="tabpanel" id="panel-2" aria-labelledby="tab-2" hidden>
    Schedule content...
  </div>
  <div role="tabpanel" id="panel-3" aria-labelledby="tab-3" hidden>
    Resources content...
  </div>
</div>

Keyboard behavior

KeyAction
TabMove into tablist, then to panel
← β†’Move between tabs
HomeFirst tab
EndLast tab

Key implementation detail

Use roving tabindex: Only the selected tab has tabindex="0"; others have tabindex="-1". Arrow keys move focus and update tabindex.

Pattern: Accordion

HTML structure

<div class="accordion">
  <h3>
    <button aria-expanded="true" aria-controls="sect1">
      Section 1
    </button>
  </h3>
  <div id="sect1" role="region" aria-labelledby="sect1-btn">
    Section 1 content...
  </div>
  
  <h3>
    <button aria-expanded="false" aria-controls="sect2">
      Section 2
    </button>
  </h3>
  <div id="sect2" role="region" aria-labelledby="sect2-btn" hidden>
    Section 2 content...
  </div>
</div>

Key points

Pattern: Dropdown menu

HTML structure

<div class="dropdown">
  <button aria-haspopup="true" 
          aria-expanded="false"
          aria-controls="menu1">
    Actions β–Ό
  </button>
  <ul role="menu" id="menu1" hidden>
    <li role="menuitem"><a href="#">Edit</a></li>
    <li role="menuitem"><a href="#">Duplicate</a></li>
    <li role="separator"></li>
    <li role="menuitem"><a href="#">Delete</a></li>
  </ul>
</div>

Keyboard behavior

KeyAction
Enter / SpaceOpen menu, focus first item
↓Open menu or next item
↑Previous item
EscapeClose menu, focus trigger
HomeFirst item
EndLast item

Pattern: Live regions

Announce dynamic content changes to screen reader users.

Types

<!-- Polite: Waits for pause in speech -->
<div aria-live="polite">
  3 items in cart
</div>

<!-- Assertive: Interrupts immediately -->
<div role="alert">
  Error: Please fill in all required fields
</div>

<!-- Status: For status updates -->
<div role="status">
  Saving...
</div>

Best practices

Testing your ARIA

Browser DevTools

Screen reader testing

Test these scenarios:

Common ARIA mistakes

MistakeFix
Using role on wrong element Check allowed child roles
Duplicate IDs for aria-labelledby Ensure IDs are unique
aria-hidden on focusable element Also set tabindex="-1"
Missing required ARIA attributes Check role requirements

Resources