Steven's Knowledge
Accessibility

ARIA

Roles, states, properties, live regions, and practical patterns for WAI-ARIA

ARIA

WAI-ARIA (Accessible Rich Internet Applications) bridges the gap between complex UI components and assistive technologies. Use it to communicate roles, states, and dynamic changes that native HTML cannot express.

ARIA Fundamentals

The Five Rules of ARIA

  1. Don't use ARIA if native HTML works<button> beats <div role="button">
  2. Don't change native semantics — don't add role="heading" to a <button>
  3. All interactive ARIA controls must be keyboard operable
  4. Don't use role="presentation" or aria-hidden="true" on focusable elements
  5. All interactive elements must have an accessible name

How Screen Readers Use ARIA

Screen Reader Announcement Components
├── Role → What it is ("button", "checkbox", "tab")
├── Name → What it's called ("Submit", "Dark mode", "Settings")
├── State → Current condition ("expanded", "checked", "selected")
└── Value → Current value ("3 of 5", "50%")

Example: "Submit, button"
Example: "Dark mode, switch, on"
Example: "Volume, slider, 75%"

Roles

Widget Roles

RoleUse CaseNative Equivalent
buttonClickable action trigger<button>
linkNavigation to a resource<a href>
checkboxBinary toggle<input type="checkbox">
radioOne-of-many selection<input type="radio">
switchOn/off toggleNone (use ARIA)
sliderRange value selector<input type="range">
textboxText input<input type="text">
comboboxInput with popup list<select> with search
listboxSelection list<select>
menuAction menu (not navigation)None (use ARIA)
menuitemItem in a menuNone (use ARIA)
tabTab in a tab listNone (use ARIA)
tabpanelContent panel for a tabNone (use ARIA)
dialogModal or non-modal dialog<dialog>
alertdialogAlert requiring acknowledgmentNone (use ARIA)
progressbarProgress indicator<progress>
tooltipHover/focus tooltipNone (use ARIA)

Composite Widget Patterns

// Tabs pattern
<div role="tablist" aria-label="Account settings">
  <button role="tab" aria-selected={activeTab === 'profile'} aria-controls="panel-profile"
    id="tab-profile" onClick={() => setActiveTab('profile')}>
    Profile
  </button>
  <button role="tab" aria-selected={activeTab === 'security'} aria-controls="panel-security"
    id="tab-security" onClick={() => setActiveTab('security')}>
    Security
  </button>
</div>

<div role="tabpanel" id="panel-profile" aria-labelledby="tab-profile"
  hidden={activeTab !== 'profile'}>
  Profile settings content
</div>
<div role="tabpanel" id="panel-security" aria-labelledby="tab-security"
  hidden={activeTab !== 'security'}>
  Security settings content
</div>

Document Structure Roles

RolePurpose
bannerSite header (implicit on <header>)
navigationNavigation section (implicit on <nav>)
mainPrimary content (implicit on <main>)
complementarySupporting content (implicit on <aside>)
contentinfoSite footer (implicit on <footer>)
regionSignificant section (requires a label)
searchSearch functionality (implicit on <search>)

States and Properties

Common States

// Expandable section
<button
  aria-expanded={isOpen}
  aria-controls="section-content"
  onClick={() => setIsOpen(!isOpen)}
>
  {isOpen ? 'Collapse' : 'Expand'} Section
</button>
<div id="section-content" hidden={!isOpen}>
  Expandable content here
</div>

// Toggle switch
<button
  role="switch"
  aria-checked={isDarkMode}
  onClick={() => setIsDarkMode(!isDarkMode)}
>
  Dark Mode
</button>

// Disabled state
<button aria-disabled="true" onClick={(e) => {
  // aria-disabled doesn't prevent click, so guard it
  if (isSubmitting) return;
  handleSubmit();
}}>
  {isSubmitting ? 'Submitting...' : 'Submit'}
</button>

aria-disabled vs disabled. Use aria-disabled="true" when you want the element to remain focusable (so screen reader users can discover it) but prevent interaction. The native disabled attribute removes the element from tab order entirely.

Property Reference

PropertyPurposeExample
aria-labelProvides accessible name directlyaria-label="Close dialog"
aria-labelledbyReferences element(s) that label thisaria-labelledby="heading-1"
aria-describedbyReferences element(s) that describe thisaria-describedby="password-hint"
aria-controlsReferences element this controlsaria-controls="dropdown-menu"
aria-ownsDefines parent-child relationship in DOMaria-owns="popup-list"
aria-requiredIndicates required inputaria-required="true"
aria-invalidIndicates validation erroraria-invalid="true"
aria-haspopupIndicates popup availabilityaria-haspopup="listbox"
aria-currentIndicates current itemaria-current="page"
aria-busyIndicates section is loadingaria-busy="true"

Naming Techniques

// 1. aria-label — when no visible label exists
<button aria-label="Close">
  <XIcon />
</button>

// 2. aria-labelledby — reference visible text
<h2 id="billing-heading">Billing Address</h2>
<form aria-labelledby="billing-heading">
  {/* form fields */}
</form>

// 3. aria-labelledby with multiple sources
<div role="dialog" aria-labelledby="dialog-title dialog-subtitle">
  <h2 id="dialog-title">Confirm Deletion</h2>
  <p id="dialog-subtitle">This action cannot be undone</p>
</div>

// 4. aria-describedby — supplementary description
<label htmlFor="password">Password</label>
<input id="password" type="password" aria-describedby="password-help" />
<p id="password-help">Must be at least 8 characters with one number.</p>

Live Regions

Live regions announce dynamic content changes to screen reader users.

Politeness Levels

LevelBehaviorUse Case
aria-live="polite"Waits for screen reader to finish current speechStatus messages, notifications
aria-live="assertive"Interrupts immediatelyErrors, urgent alerts
role="status"Implicit aria-live="polite"Form feedback, counters
role="alert"Implicit aria-live="assertive"Error messages
role="log"Implicit aria-live="polite"Chat, activity logs

Implementation Patterns

// Status message — polite announcement
function SearchResults({ count }: { count: number }) {
  return (
    <div role="status" aria-live="polite">
      {count} results found
    </div>
  );
}

// Error alert — assertive announcement
function FormError({ message }: { message: string }) {
  return (
    <div role="alert">
      {message}
    </div>
  );
}

// Toast notification system
function ToastContainer({ toasts }: { toasts: Toast[] }) {
  return (
    <div aria-live="polite" aria-relevant="additions">
      {toasts.map((toast) => (
        <div key={toast.id} role="status">
          {toast.message}
        </div>
      ))}
    </div>
  );
}

// Loading state with aria-busy
function DataTable({ isLoading, data }: DataTableProps) {
  return (
    <div aria-busy={isLoading} aria-live="polite">
      {isLoading ? (
        <p>Loading data...</p>
      ) : (
        <table>{/* table content */}</table>
      )}
    </div>
  );
}

Live region must exist in DOM before content changes. Render the container element on initial load (can be empty), then update its content. If the live region itself is dynamically inserted, screen readers may miss the announcement.

Hiding Content

Techniques Comparison

TechniqueVisualScreen ReaderFocus
aria-hidden="true"VisibleHiddenMust not be focusable
display: noneHiddenHiddenNot focusable
visibility: hiddenHidden (keeps space)HiddenNot focusable
.sr-only (visually hidden)HiddenAccessibleFocusable
hidden attributeHiddenHiddenNot focusable
/* Visually hidden but accessible to screen readers */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}
// Decorative icon — hide from screen readers
<button>
  <SearchIcon aria-hidden="true" />
  Search
</button>

// Icon-only button — provide text for screen readers
<button aria-label="Search">
  <SearchIcon aria-hidden="true" />
</button>

// Visually hidden text for additional context
<a href="/blog">
  Read more<span className="sr-only"> about accessibility testing</span>
</a>

Common ARIA Patterns

Disclosure (Show/Hide)

function Disclosure({ title, children }: DisclosureProps) {
  const [isOpen, setIsOpen] = useState(false);
  const contentId = useId();

  return (
    <div>
      <button
        aria-expanded={isOpen}
        aria-controls={contentId}
        onClick={() => setIsOpen(!isOpen)}
      >
        {title}
      </button>
      <div id={contentId} hidden={!isOpen}>
        {children}
      </div>
    </div>
  );
}

Combobox (Autocomplete)

function Combobox({ options, onSelect }: ComboboxProps) {
  const [query, setQuery] = useState('');
  const [isOpen, setIsOpen] = useState(false);
  const [activeIndex, setActiveIndex] = useState(-1);
  const listboxId = useId();

  const filtered = options.filter((o) =>
    o.label.toLowerCase().includes(query.toLowerCase())
  );

  return (
    <div>
      <input
        role="combobox"
        aria-expanded={isOpen}
        aria-controls={listboxId}
        aria-activedescendant={activeIndex >= 0 ? `option-${activeIndex}` : undefined}
        aria-autocomplete="list"
        value={query}
        onChange={(e) => {
          setQuery(e.target.value);
          setIsOpen(true);
        }}
        onKeyDown={(e) => {
          if (e.key === 'ArrowDown') {
            setActiveIndex((i) => Math.min(i + 1, filtered.length - 1));
          } else if (e.key === 'ArrowUp') {
            setActiveIndex((i) => Math.max(i - 1, 0));
          } else if (e.key === 'Enter' && activeIndex >= 0) {
            onSelect(filtered[activeIndex]);
            setIsOpen(false);
          } else if (e.key === 'Escape') {
            setIsOpen(false);
          }
        }}
      />
      {isOpen && (
        <ul role="listbox" id={listboxId}>
          {filtered.map((option, index) => (
            <li
              key={option.value}
              role="option"
              id={`option-${index}`}
              aria-selected={index === activeIndex}
              onClick={() => {
                onSelect(option);
                setIsOpen(false);
              }}
            >
              {option.label}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

Best Practices

ARIA Usage Guidelines

  1. Prefer native HTML elements over ARIA roles
  2. Every interactive element needs an accessible name
  3. Keep aria-live regions in the DOM from page load
  4. Use aria-hidden="true" for decorative elements only
  5. Never put aria-hidden on focusable elements
  6. Test with screen readers — automated tools miss ARIA issues
  7. Use aria-current="page" for current navigation item
  8. Match ARIA states to visual states — don't let them drift apart

On this page