ARIA Patterns
Apply ARIA roles, states, and properties correctly to enhance assistive technology support for custom widgets
When to Use
- Building custom interactive widgets that have no native HTML equivalent
- Adding accessible names or descriptions to elements
- Announcing dynamic content changes to screen readers
- Indicating expanded/collapsed, selected, or error states
- When native HTML semantics are insufficient (and only then)
Instructions
Follow the first rule of ARIA: do not use ARIA if native HTML works. A
<button>is better than<div role="button">. A<nav>is better than<div role="navigation">. ARIA overrides semantics; native HTML provides them for free.Use
aria-labelandaria-labelledbyto provide accessible names. Every interactive element needs an accessible name — screen readers announce it.
// aria-label — inline text label (when no visible label exists)
<button aria-label="Close dialog">
<XIcon />
</button>
// aria-labelledby — reference a visible label element
<h2 id="dialog-title">Confirm Deletion</h2>
<div role="dialog" aria-labelledby="dialog-title">
- Prefer
aria-labelledbyoveraria-labelwhen a visible text element exists — it avoids translation gaps. aria-labelreplaces the element's visible text for screen readers. If the button says "X",aria-label="Close"makes screen readers say "Close button."
- Use
aria-describedbyfor supplementary information. Unlikearia-labelledby(which names the element),aria-describedbyprovides additional context read after the name.
<input
type="password"
aria-label="Password"
aria-describedby="password-help"
/>
<p id="password-help">Must be at least 8 characters with one number.</p>
- Use
aria-liveregions to announce dynamic content. When content updates without a page reload (toast notifications, form validation, live scores), usearia-liveto announce the change.
// polite — waits for the screen reader to finish current speech
<div aria-live="polite" role="status">
{statusMessage}
</div>
// assertive — interrupts current speech (use sparingly)
<div aria-live="assertive" role="alert">
{errorMessage}
</div>
- Render the live region in the DOM before populating it — screen readers only track changes to existing live regions.
- Use
role="status"for informational updates androle="alert"for urgent errors.
- Use state attributes to reflect widget state. Keep ARIA states synchronized with visual state.
// Expandable section
<button
aria-expanded={isOpen}
aria-controls="panel-1"
=> setIsOpen(!isOpen)}
>
Settings
</button>
<div id="panel-1" hidden={!isOpen}>
{/* panel content */}
</div>
// Toggle button
<button aria-pressed={isMuted}
Mute
</button>
// Disabled state
<button aria-disabled={isSubmitting} ? undefined : handleSubmit}>
Submit
</button>
- Use
aria-hidden="true"to hide decorative or redundant content from screen readers. Icons next to text labels, decorative images, and duplicate content should be hidden.
<button>
<SearchIcon aria-hidden="true" />
<span>Search</span>
</button>
Do not use aria-hidden="true" on focusable elements — it creates a confusing state where the element receives focus but is invisible to assistive technology.
- Use roles for custom widgets that have no native equivalent. Common role patterns:
// Tab interface
<div role="tablist">
<button role="tab" aria-selected={activeTab === 0} aria-controls="panel-0">Tab 1</button>
<button role="tab" aria-selected={activeTab === 1} aria-controls="panel-1">Tab 2</button>
</div>
<div role="tabpanel" id="panel-0" aria-labelledby="tab-0">Content 1</div>
// Combobox (autocomplete)
<input role="combobox" aria-expanded={isOpen} aria-controls="listbox-1" aria-activedescendant={activeOptionId} />
<ul role="listbox" id="listbox-1">
<li role="option" id="opt-1" aria-selected={selected === 'opt-1'}>Option 1</li>
</ul>
// Alert dialog
<div role="alertdialog" aria-labelledby="alert-title" aria-describedby="alert-desc">
<h2 id="alert-title">Delete Account?</h2>
<p id="alert-desc">This action cannot be undone.</p>
<button>Cancel</button>
<button>Delete</button>
</div>
- Use
aria-invalidandaria-errormessagefor form validation errors.
<input
aria-invalid={!!errors.email}
aria-errormessage={errors.email ? 'email-error' : undefined}
/>;
{
errors.email && (
<span id="email-error" role="alert">
{errors.email}
</span>
);
}
Details
ARIA categories:
- Roles: Define what an element is (e.g.,
tab,dialog,alert,progressbar). Set once; do not change dynamically. - States: Dynamic boolean/tristate values that change with user interaction (e.g.,
aria-expanded,aria-selected,aria-pressed). - Properties: Relatively static attributes that describe relationships or characteristics (e.g.,
aria-label,aria-describedby,aria-controls).
The five rules of ARIA:
- Do not use ARIA if native HTML works.
- Do not change native semantics (do not put
role="button"on an<a>). - All interactive ARIA elements must be keyboard-operable.
- Do not use
role="presentation"oraria-hidden="true"on focusable elements. - All interactive elements must have an accessible name.
Common mistakes:
- Adding
role="button"without keyboard support (Enter and Space activation) - Using
aria-labelon non-interactive elements where it has no effect - Setting
aria-expandedwithout updating it when state changes - Overusing
aria-live="assertive"(interrupts users constantly) - Using
aria-hidden="true"on a parent containing focusable children
Testing: Use the accessibility tree in browser DevTools to verify that ARIA attributes produce the expected accessible name, role, and state.
Source
https://www.w3.org/TR/wai-aria-1.2/
Process
- Read the instructions and examples in this document.
- Apply the patterns to your implementation, adapting to your specific context.
- Verify your implementation against the details and edge cases listed above.
Harness Integration
- Type: knowledge — this skill is a reference document consumed as context, but two of its assertions are also load-bearing: they are enforced mechanically by
harness-accessibilityvia theAriaScannerin@harness-engineering/core.A11Y-014—aria-hidden="true"on a focusable element (rule #4 of ARIA: do not hide a focusable control from assistive technology).A11Y-042— positivetabindex, which disrupts the natural tab order. These fire only on statically-decidable values (a dynamicaria-hidden={expr}or atabIndex={0}/tabIndex={-1}is never flagged), keeping false positives near zero.
- What remains advisory (not mechanized): accessible-name presence (
aria-label/aria-labelledby), role-appropriate keyboard operability, live-region and state-attribute correctness. These require resolving relationships across elements or runtime state and cannot be enforced at a low false-positive rate by pattern matching, so they stay prose guidance here. - No tools or state — consumed as context by other skills and agents.
Success Criteria
- The patterns described in this document are applied correctly in the implementation.
- Edge cases and anti-patterns listed in this document are avoided.