---
title: "HTML: ARIA Patterns"
slug: "html-aria-patterns"
category: "frontend"
tags: ["frontend", "html", "aria", "accessibility", "patterns"]
status: "stable"
last_updated: 2026-05-14
summary: "Common ARIA patterns for live regions, dialogs, disclosure, tabs, and listbox, with the first rule of ARIA applied throughout."
related: ["[[frontend/accessibility]]", "[[frontend/html]]", "[[frontend/html-semantic-elements]]", "[[frontend/forms]]", "[[frontend/react]]"]
---

## Overview

ARIA (Accessible Rich Internet Applications) is a set of attributes that modify what assistive technology announces. It does not change visual appearance or keyboard behavior. A correct ARIA implementation starts with the right semantic element and adds ARIA only where the native element falls short.

## The first rule of ARIA: do not use ARIA

If a native HTML element or attribute provides the semantics you need, use it. Native elements carry implicit roles, states, and properties that assistive technology has understood for decades.

- Use `<button>` instead of `<div role="button" tabindex="0">`.
- Use `<input type="checkbox">` instead of `<div role="checkbox" aria-checked="false">`.
- Use `<nav>` instead of `<div role="navigation">`.
- Use `<details>` and `<summary>` instead of a manually toggled disclosure widget.

The second rule of ARIA: do not change native semantics unless you have no choice. Adding `role="presentation"` to a `<table>` that contains tabular data removes the semantics screen readers need.

## Roles, states, and properties are three distinct concepts

- **Roles** describe what a widget is: `role="dialog"`, `role="tab"`, `role="listbox"`. Set once; do not change dynamically.
- **States** describe the current condition of a widget: `aria-expanded="true"`, `aria-checked="true"`, `aria-disabled="true"`. Change on user interaction.
- **Properties** describe characteristics that do not change with state: `aria-label="Close"`, `aria-haspopup="listbox"`, `aria-required="true"`.

Mix them correctly. `aria-expanded` is a state, so toggle it. `aria-label` is a property, so set it once.

## Live regions announce async updates without focus movement

`aria-live="polite"` announces content changes at the next pause in speech. Use it for status messages, search results updating in place, and non-critical notifications.

`aria-live="assertive"` interrupts the current announcement. Reserve it for errors or critical alerts only. Overusing `assertive` is disruptive.

```html
<div role="status" aria-live="polite" aria-atomic="true">
  3 results found.
</div>
```

`aria-atomic="true"` causes the entire region to be re-announced as a unit when any part changes, instead of announcing only the changed node. Use it when partial announcements would be confusing.

## Dialogs require role, label, and focus management

A modal dialog needs `role="dialog"`, an accessible name, and focus trapped inside it while open.

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

When the dialog opens, move focus to the first focusable element or to the dialog element itself. When it closes, return focus to the trigger. Without this, keyboard users lose their place. See [[frontend/accessibility]] for the keyboard trap requirement.

## Expandable sections use aria-expanded on the trigger

Toggle `aria-expanded` on the button that controls a collapsible region. The `aria-controls` attribute names the region by `id`.

```html
<button aria-expanded="false" aria-controls="panel-1">Show details</button>
<div id="panel-1" hidden>
  Details content here.
</div>
```

Update `aria-expanded` and toggle `hidden` (or CSS `display: none`) together. If the button's visible label changes between "Show" and "Hide," the label communicates the current action; `aria-expanded` still communicates the current state, and both are useful to assistive tech.

## Tabs require coordinated roles on three elements

A tab pattern needs `role="tablist"` on the container, `role="tab"` on each button, and `role="tabpanel"` on each panel. Connect each tab to its panel with `aria-controls` and `aria-labelledby`.

```html
<div role="tablist" aria-label="Product features">
  <button role="tab" id="tab-1" aria-selected="true" aria-controls="panel-tab-1">Overview</button>
  <button role="tab" id="tab-2" aria-selected="false" aria-controls="panel-tab-2">Specs</button>
</div>
<div role="tabpanel" id="panel-tab-1" aria-labelledby="tab-1">Overview content.</div>
<div role="tabpanel" id="panel-tab-2" aria-labelledby="tab-2" hidden>Specs content.</div>
```

Keyboard behavior: Tab moves into the tablist; arrow keys move between tabs; Enter or Space activates. Do not reinvent this; follow the WAI-ARIA Authoring Practices tab pattern exactly.

## Listbox for custom select-style widgets

When a native `<select>` is visually insufficient, use `role="listbox"` with `role="option"` children. Set `aria-selected="true"` on the current selection and manage keyboard navigation (Up, Down, Home, End, Enter, Escape) manually.

```html
<ul role="listbox" aria-label="Choose a framework" tabindex="0">
  <li role="option" aria-selected="true" id="opt-1">Astro</li>
  <li role="option" aria-selected="false" id="opt-2">Next.js</li>
</ul>
```

A half-built `listbox` that does not handle keyboard input is worse than a plain `<select>`. If you cannot commit to the full pattern, use a styled native `<select>`. For the React-specific state management that these patterns need, see [[frontend/react]].

## Related

- [[frontend/accessibility]]
- [[frontend/html]]
- [[frontend/html-semantic-elements]]
- [[frontend/forms]]
- [[frontend/react]]
