Skip to main content

ARIA States and Dynamic Updates

Master ARIA states — aria-expanded, aria-hidden, aria-disabled, and dynamic state management

Question: What are ARIA states and how do you manage them?

Difficulty: 🟡 Medium | Frequency: ⭐⭐⭐⭐⭐ | Companies: Google, Meta, Amazon, Microsoft, Apple

Explain ARIA states like aria-expanded, aria-hidden, aria-disabled, aria-selected, aria-checked. How do you keep them synchronized with visual state changes?

Answer

ARIA states communicate the current status of interactive elements to assistive technologies. Unlike properties (mostly static), states change frequently and MUST stay synchronized with visual changes.

Key ARIA States

StateValuesUse Case
aria-expandedtrue/falseAccordions, dropdowns, menus
aria-hiddentrue/falseHides from accessibility tree
aria-disabledtrue/falseDisabled (different from disabled attribute)
aria-selectedtrue/falseTabs, listbox options
aria-checkedtrue/false/mixedCheckboxes, radios, switches
aria-pressedtrue/falseToggle buttons
aria-busytrue/falseLoading/processing
aria-invalidtrue/falseForm validation errors

The Golden Rule

Visual state MUST match ARIA state. When you toggle an accordion open, update aria-expanded to "true" at the same time.

Code Examples

aria-expanded — Accordion/Dropdown

<button
id="menu-btn"
aria-expanded="false"
aria-controls="menu"
onclick="toggleMenu()"
>
Menu <span aria-hidden="true"></span>
</button>

<div id="menu" hidden>
<a href="/home">Home</a>
<a href="/about">About</a>
</div>

<script>
function toggleMenu() {
const button = document.getElementById('menu-btn');
const menu = document.getElementById('menu');
const isExpanded = button.getAttribute('aria-expanded') === 'true';

// Update ARIA and visual state simultaneously
button.setAttribute('aria-expanded', !isExpanded);
menu.hidden = isExpanded;
}
</script>

aria-hidden — Visibility Control

<!-- Hide decorative elements from screen readers -->
<button aria-label="Delete">
<span aria-hidden="true">🗑️</span>
</button>

<!-- Hide background when modal is open -->
<div class="page-content" aria-hidden="true"><!-- hidden from SR --></div>
<div role="dialog" aria-modal="true" aria-labelledby="dialog-title">
<h2 id="dialog-title">Confirm Delete</h2>
</div>

aria-disabled vs disabled attribute

<!-- disabled attribute: removes from tab order, prevents ALL interaction -->
<button disabled>Submit</button>

<!-- aria-disabled: keeps in tab order, requires JS to prevent interaction -->
<button
aria-disabled="true"
aria-describedby="reason"
onclick="handleClick(event)"
>
Submit
</button>
<span id="reason">Please fill in all required fields</span>

<script>
function handleClick(event) {
if (event.currentTarget.getAttribute('aria-disabled') === 'true') {
event.preventDefault();
return;
}
// Normal submit logic
}
</script>

Use aria-disabled when you want keyboard users to discover the element and understand WHY it's disabled.

aria-selected — Tabs

<div role="tablist" aria-label="Settings">
<button role="tab" id="tab-1" aria-selected="true" aria-controls="panel-1" tabindex="0">General</button>
<button role="tab" id="tab-2" aria-selected="false" aria-controls="panel-2" tabindex="-1">Privacy</button>
</div>

<div role="tabpanel" id="panel-1" aria-labelledby="tab-1">General Settings</div>
<div role="tabpanel" id="panel-2" aria-labelledby="tab-2" hidden>Privacy Settings</div>

aria-invalid — Form Validation

<label for="email">Email</label>
<input
type="email"
id="email"
aria-required="true"
aria-invalid="false"
aria-describedby="email-error"
onblur="validateEmail()"
>
<span id="email-error" role="alert" hidden>Please enter a valid email</span>

<script>
function validateEmail() {
const input = document.getElementById('email');
const error = document.getElementById('email-error');
const isValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input.value);

input.setAttribute('aria-invalid', !isValid);
error.hidden = isValid;
}
</script>

React: Accordion with Synchronized State

function AccessibleAccordion({ items }) {
const [openIndex, setOpenIndex] = useState(null);
const accordionId = useId();

const toggle = (index) => setOpenIndex(prev => prev === index ? null : index);

const handleKeyDown = (e, index) => {
const buttons = document.querySelectorAll(`[data-accordion="${accordionId}"] button`);
const current = Array.from(buttons).indexOf(e.target);
if (e.key === 'ArrowDown') { buttons[(current + 1) % buttons.length].focus(); e.preventDefault(); }
if (e.key === 'ArrowUp') { buttons[(current - 1 + buttons.length) % buttons.length].focus(); e.preventDefault(); }
if (e.key === 'Home') { buttons[0].focus(); e.preventDefault(); }
if (e.key === 'End') { buttons[buttons.length - 1].focus(); e.preventDefault(); }
};

return (
<div data-accordion={accordionId}>
{items.map((item, index) => {
const isOpen = openIndex === index;
const buttonId = `${accordionId}-btn-${index}`;
const panelId = `${accordionId}-panel-${index}`;

return (
<div key={index}>
<button
id={buttonId}
aria-expanded={isOpen}
aria-controls={panelId}
onClick={() => toggle(index)}
onKeyDown={(e) => handleKeyDown(e, index)}
>
{item.title}
<span aria-hidden="true">{isOpen ? '▼' : '▶'}</span>
</button>
<div id={panelId} role="region" aria-labelledby={buttonId} hidden={!isOpen}>
{item.content}
</div>
</div>
);
})}
</div>
);
}

Common Mistakes

<!-- ❌ aria-expanded without aria-controls -->
<button aria-expanded="false">Menu</button>

<!-- ✅ Link state to controlled element -->
<button aria-expanded="false" aria-controls="menu">Menu</button>

<!-- ❌ aria-hidden on focusable element (still reachable via Tab!) -->
<button aria-hidden="true">Click me</button>

<!-- ✅ Use hidden attribute for complete removal -->
<button hidden>Click me</button>

Deep Dive: State Update Timing

When you update an ARIA state attribute:

  1. DOM mutation occurs (setAttribute)
  2. Browser updates accessibility tree (5–50ms delay)
  3. Accessibility API notifies screen reader
  4. Screen reader announces change

Critical: Update ARIA state synchronously with visual changes, not in a setTimeout or useEffect that fires after render. Async updates can cause mismatches where the screen reader reads stale state.

// ❌ Bad: Async state update via useEffect
useEffect(() => {
button?.setAttribute('aria-expanded', isOpen);
}, [isOpen]);

// ✅ Good: Synchronous in render (React)
<button aria-expanded={isOpen} onClick={toggle}>Menu</button>

Decision Guide: disabled vs aria-disabled

NeedUse
Prevent interaction entirelydisabled attribute
Keep in tab order so SR can discover why it's disabledaria-disabled="true" + aria-describedby
Temporary loading statearia-busy="true" + disabled

Follow-up Questions

  • What's the difference between hidden and aria-hidden?
  • When should you use aria-disabled instead of the disabled attribute?
  • What happens if aria-expanded and visual state don't match?
  • When should you use aria-pressed vs aria-checked?

Resources


Content from Frontend-Master-Prep-Series08-accessibility