Advanced ARIA Patterns
Building accessible custom widgets with ARIA roles, live regions, and managed focus.
3 min read
The Accessibility Basics lesson covered the first rule of ARIA: prefer a native element over adding ARIA to a generic one. This lesson covers what to do for the cases native HTML genuinely has no element for — tabs, modals, comboboxes — where ARIA becomes necessary rather than optional.
The three parts of ARIA
Every custom widget pattern combines three kinds of attributes:
- Roles (
role="tablist") — tell assistive technology what a generic element is, since<div>and<span>carry no meaning of their own. - Properties (
aria-controls,aria-labelledby) — describe relationships between elements. - States (
aria-selected,aria-expanded) — describe current, changeable status, and must be kept in sync with JavaScript as the widget's state changes.
A tabs widget
<div role="tablist" aria-label="Account settings">
<button role="tab" id="tab-profile" aria-selected="true" aria-controls="panel-profile">
Profile
</button>
<button role="tab" id="tab-billing" aria-selected="false" aria-controls="panel-billing" tabindex="-1">
Billing
</button>
</div>
<div role="tabpanel" id="panel-profile" aria-labelledby="tab-profile">
Profile settings...
</div>
<div role="tabpanel" id="panel-billing" aria-labelledby="tab-billing" hidden>
Billing settings...
</div>aria-selected marks the active tab, aria-controls points from a tab to the panel it reveals, and aria-labelledby points back the other way so the panel announces which tab it belongs to. The inactive tab's tabindex="-1" removes it from the regular tab order — a full tabs pattern moves focus between tabs with arrow keys instead, handled entirely in JavaScript.
Live regions: announcing dynamic changes
<div aria-live="polite" id="cart-status"></div>
<button onclick="document.getElementById('cart-status').textContent = 'Added to cart'">
Add to Cart
</button>A screen reader only announces content automatically when it changes inside a live region. aria-live="polite" announces the update once the user's current activity pauses (the right choice for most status messages); aria-live="assertive" interrupts immediately and should be reserved for genuinely urgent updates, like a form error blocking submission. Without a live region, updating textContent on an ordinary <div> is completely silent to a screen reader — nothing gets announced at all.
Focus management in a modal dialog
<dialog id="confirm-dialog" aria-labelledby="dialog-title">
<h2 id="dialog-title">Delete this item?</h2>
<p>This action cannot be undone.</p>
<button id="cancel-btn">Cancel</button>
<button id="confirm-btn">Delete</button>
</dialog>const dialog = document.getElementById("confirm-dialog");
dialog.showModal();
document.getElementById("cancel-btn").focus();The native <dialog> element (opened with showModal()) already handles most of the hard accessibility work for free: it traps focus inside itself, blocks interaction with the rest of the page, and returns focus to whatever triggered it when closed. When building a custom modal without <dialog>, all of that — trapping Tab inside the dialog, restoring focus on close, closing on Escape — has to be implemented by hand, which is exactly why reaching for <dialog> first avoids reinventing a surprisingly large amount of behavior.
The rule that ties it all together
An ARIA attribute never changes how an element behaves or looks — it only changes what assistive technology is told about it. Setting aria-expanded="true" on a button announces "expanded" to a screen reader, but does nothing to actually show or hide anything; the JavaScript toggling that attribute must also be the JavaScript that shows the content. ARIA describes state, it doesn't create it — every attribute here has to be kept honest by the code driving the widget, or it actively lies to the users relying on it.
Test what you just learned
4 quick questions. Get all of them right to unlock the next lesson.
You can take the quiz without an account — logging in just lets your result count toward your progress.