ARIA Patterns That Actually Help

WebAccessibilityARIAFrontend
Share on LinkedIn

A client ran an accessibility audit and got flagged for 47 ARIA violations — and 31 of them were on pages where developers had added ARIA thinking they were helping. role="button" on a <button>, aria-label duplicating visible text, role="navigation" on a <nav>. The pages were less accessible after the ARIA pass than before. ARIA is a power tool: used correctly it makes custom widgets usable by screen readers; used incorrectly it overrides the browser's built-in semantics and confuses assistive technology. Here's how to get it right.

Rule zero: native HTML first

Before reaching for ARIA, check if HTML already solves it:

Need Use this Not this
Button <button> <div role="button" tabindex="0">
Link <a href="..."> <span role="link">
Navigation <nav> <div role="navigation">
Heading <h1><h6> <div role="heading" aria-level="1">
Text input <input type="text"> <div contenteditable role="textbox">
Checkbox <input type="checkbox"> <div role="checkbox" aria-checked>

Native elements come with keyboard handling, focus management, and screen reader support for free.

When ARIA is necessary

Custom widgets that have no HTML equivalent:

For these, follow the WAI-ARIA Authoring Practices Guide patterns exactly. They specify roles, keyboard interactions, and focus management for each widget.

Roles: what something is

<!-- Tab interface -->
<div role="tablist">
  <button role="tab" aria-selected="true" aria-controls="panel-1" id="tab-1">
    Account
  </button>
  <button role="tab" aria-selected="false" aria-controls="panel-2" id="tab-2">
    Settings
  </button>
</div>
<div role="tabpanel" id="panel-1" aria-labelledby="tab-1">
  Account content
</div>
<div role="tabpanel" id="panel-2" aria-labelledby="tab-2" hidden>
  Settings content
</div>

Each role carries expected keyboard behavior. Tabs require Arrow key navigation, Home/End, and automatic activation or manual activation depending on the pattern.

States: what changes

States update as the user interacts:

<!-- Disclosure widget -->
<button aria-expanded="false" aria-controls="details-section">
  Show details
</button>
<div id="details-section" hidden>
  Detail content here
</div>

<script>
  button.addEventListener('click', () => {
    const expanded = button.getAttribute('aria-expanded') === 'true';
    button.setAttribute('aria-expanded', String(!expanded));
    details.hidden = expanded;
  });
</script>

Critical states:

Properties: relationships

<!-- Form field with label and error -->
<label id="email-label" for="email">Email</label>
<input id="email" type="email"
       aria-labelledby="email-label"
       aria-describedby="email-error"
       aria-invalid="true">
<span id="email-error" role="alert">Enter a valid email address</span>

Live regions: announce dynamic changes

<!-- Search results count -->
<div aria-live="polite" aria-atomic="true" class="sr-only">
  {{ resultCount }} results found
</div>

<!-- Error alert -->
<div role="alert" aria-live="assertive">
  Your session has expired. Please log in again.
</div>

<!-- Loading status -->
<div role="status" aria-live="polite">
  Loading results...
</div>
Attribute Behavior
aria-live="polite" Announces when user is idle
aria-live="assertive" Interrupts current speech
aria-atomic="true" Reads entire region, not just changes
role="alert" Implicit assertive live region
role="status" Implicit polite live region

Use polite for most updates. Reserve assertive for errors and urgent warnings.

Common mistakes

Redundant ARIA. Don't add role="button" to <button> or aria-label="Close" when the button text already says "Close."

Missing keyboard support. role="button" on a <div> requires you to implement Enter/Space key handling and focus management. Use <button> instead.

aria-hidden on focusable elements. Never hide interactive elements with aria-hidden="true" — screen readers skip them but keyboard users can still tab to them.

Stale states. If aria-expanded doesn't update when the panel opens, screen readers report the wrong state. Keep ARIA states in sync with visual state.

Over-nesting roles. One role per element. Don't put role="tab" inside role="tablist" inside role="navigation" inside role="main" unless each is semantically correct.

Testing ARIA

  1. Automated scan — axe-core or Lighthouse catch missing labels and invalid roles
  2. Screen reader test — VoiceOver (macOS), NVDA (Windows), or TalkBack (Android)
  3. Keyboard-only navigation — Tab through the page, verify focus is visible and actions work
  4. Inspect accessibility tree — Chrome DevTools → Elements → Accessibility pane

Automated tools catch ~30% of accessibility issues. Screen reader testing catches the rest.

Common production mistakes

Teams get accessibility aria patterns wrong in predictable ways:

Production implementations of accessibility aria patterns fail when staging mirrors production topology poorly, rollback is untested, and on-call runbooks describe the happy path only.

Debugging and triage workflow

When accessibility aria patterns misbehaves in production, work top-down instead of guessing:

  1. Confirm scope — one tenant, region, or deployment stage? Narrow blast radius before deep diving.
  2. Check recent changes — deploys, flag flips, config pushes, and schema migrations in the last 24 hours.
  3. Compare golden signals — latency, error rate, saturation, and traffic for the affected surface vs. baseline.
  4. Reproduce minimally — smallest input or scenario that triggers the failure; capture traces/logs with correlation IDs.
  5. Fix forward or rollback — if rollback is faster than root-cause during incident, rollback first, postmortem second.
  6. Add a guard — alert, integration test, or circuit breaker so the same class of failure is caught earlier next time.

Document the timeline during triage. Future you (and on-call) will need timestamps, not just conclusions.

Resources

Frequently asked questions

When should I use ARIA attributes on web elements?

Use ARIA only when native HTML elements cannot express the required semantics. A button should be a button element, not a div with role=button. ARIA fills gaps for custom widgets — tabs, tree views, comboboxes — where no native HTML equivalent exists. The first rule of ARIA is to not use ARIA if a native element can do the job. Misapplied ARIA overrides native semantics and makes the page less accessible, not more.

What is the difference between ARIA roles, states, and properties?

Roles define what an element is — button, dialog, tab, alert. States describe current conditions that change, like aria-expanded, aria-checked, or aria-disabled. Properties describe relationships or metadata that are relatively stable, like aria-labelledby, aria-describedby, or aria-controls. Roles are set once; states update dynamically as the user interacts; properties connect elements to each other.

What are ARIA live regions and when do I need them?

Live regions announce dynamic content changes to screen readers without requiring the user to navigate to the changed area. Use aria-live=polite for non-urgent updates like form validation messages or search result counts. Use aria-live=assertive for urgent alerts like error notifications. The role=alert and role=status elements have implicit live region behavior. Without live regions, screen reader users miss content that updates after the initial page load.

Hiring a senior Android / Flutter engineer?

I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.

Get in touch →