Skip to content
Accessibility

ARIA Attributes Guide

When and how to use ARIA roles, states, and properties — and when to avoid them.

stellae.design

10 min read

Introduction

ARIA (Accessible Rich Internet Applications) is a set of attributes that supplement HTML to communicate roles, states, and properties to assistive technologies. It bridges the gap when native HTML elements can't express the semantics of custom widgets.

But ARIA is frequently misused. The first rule of ARIA is: don't use ARIA if native HTML can do the job. A <button> is always better than <div role='button'>. ARIA doesn't add behavior — it only changes what screen readers announce.

This guide covers the most important ARIA attributes, when to use them, and common patterns for custom widgets.


Key Concepts

ARIA Roles

html
<!-- Landmark roles (prefer semantic HTML) -->
<nav>...</nav>                    <!-- Better than <div role="navigation"> -->
<main>...</main>                  <!-- Better than <div role="main"> -->
<div role="search">...</div>      <!-- No HTML equivalent -->

<!-- Widget roles -->
<div role="tablist">
  <button role="tab" aria-selected="true">Tab 1</button>
  <button role="tab" aria-selected="false">Tab 2</button>
</div>
<div role="tabpanel">Content</div>

<!-- Live region roles -->
<div role="alert">Error message</div>  <!-- Assertive announcement -->
<div role="status">3 results</div>     <!-- Polite announcement -->

ARIA States and Properties

html
// States (change dynamically)
aria-expanded="true"      // Dropdown open
aria-selected="true"      // Tab or option selected
aria-checked="true"       // Checkbox checked
aria-disabled="true"      // Disabled (but still focusable)
aria-hidden="true"        // Hidden from assistive technology
aria-busy="true"          // Loading
aria-invalid="true"       // Form validation error
aria-pressed="true"       // Toggle button active

// Properties (relatively static)
aria-label="Close dialog"         // Accessible name
aria-labelledby="heading-id"      // Reference to labeling element
aria-describedby="help-text-id"   // Reference to description
aria-controls="panel-id"          // What this element controls
aria-live="polite"                // Announce changes
aria-required="true"              // Required form field

Practical Examples

1. Custom Dropdown

typescript
function Dropdown({ label, options, value, onChange }) {
  const [open, setOpen] = useState(false);
  return (
    <div className="relative">
      <button
        aria-haspopup="listbox"
        aria-expanded={open}
        aria-label={label}
        onClick={() => setOpen(!open)}
      >
        {value || 'Select...'}
      </button>
      {open && (
        <ul role="listbox" aria-label={label}>
          {options.map(opt => (
            <li key={opt} role="option" aria-selected={opt === value}
              onClick={() => { onChange(opt); setOpen(false); }}>
              {opt}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

2. Accordion

typescript
function Accordion({ items }) {
  const [openIndex, setOpenIndex] = useState<number | null>(null);
  return (
    <div>
      {items.map((item, i) => (
        <div key={i}>
          <h3>
            <button
              aria-expanded={openIndex === i}
              aria-controls={`panel-${i}`}
              onClick={() => setOpenIndex(openIndex === i ? null : i)}
            >
              {item.title}
            </button>
          </h3>
          <div id={`panel-${i}`} role="region" aria-labelledby={`heading-${i}`}
            hidden={openIndex !== i}>
            {item.content}
          </div>
        </div>
      ))}
    </div>
  );
}

3. Toast Notification

typescript
// Use aria-live for non-urgent updates
function ToastContainer({ toasts }) {
  return (
    <div aria-live="polite" aria-atomic="false" className="fixed bottom-4 right-4">
      {toasts.map(toast => (
        <div key={toast.id} role="status" className="rounded bg-white p-4 shadow">
          {toast.message}
        </div>
      ))}
    </div>
  );
}

// Use role="alert" for urgent errors
<div role="alert">Payment failed. Please try again.</div>

Best Practices

  • ✅ Use native HTML elements first — <button>, <select>, <input> over ARIA widgets
  • ✅ Always pair role with required states (tab needs aria-selected, checkbox needs aria-checked)
  • ✅ Use aria-labelledby to reference visible text instead of duplicating with aria-label
  • ✅ Use aria-describedby for supplementary information (help text, error messages)
  • ✅ Test with a screen reader after adding ARIA — verify it announces correctly
  • ❌ Don't use role='button' on a <div> — use <button> instead
  • ❌ Don't use aria-hidden='true' on focusable elements — creates confusing ghost elements

Common Pitfalls

  • Adding aria-label to elements with visible text — screen readers may ignore the visible text
  • Using aria-hidden on a parent that contains focusable children
  • Forgetting to update aria-expanded when a dropdown opens/closes
  • Using aria-live='assertive' for non-urgent updates — interrupts the user's reading flow

Accessibilityaccessibility

Was this article helpful?