Handling Dynamic ARIA States in Modern JavaScript Frameworks
An ARIA state attribute is a promise that the DOM tells assistive technology the same thing the pixels tell everyone else. This guide is part of DOM Inspection for Dynamic Content, and it covers how to assert that aria-expanded, aria-selected, aria-checked, aria-pressed and aria-disabled actually track component state through a framework’s render cycle — that they are written as the strings the specification defines rather than dropped by a boolean binding, that they are read after the batched update lands rather than a tick before, and that they cannot drift away from the visual state a controlled component is rendering.
Root Cause
The first cause is that these attributes are strings with three distinct meanings, and a JavaScript boolean only has two. aria-expanded="true" means expanded. aria-expanded="false" means collapsed, and a screen reader announces “collapsed” for it. An absent aria-expanded means something else again: this control has no expandable state, so nothing is announced and the user has no idea a panel is attached to the button. The gap between "false" and absent is the entire defect, and it is invisible in a screenshot.
Frameworks decide how a JavaScript value becomes an attribute, and they do not agree. React stringifies booleans for any attribute in the aria- namespace, so aria-expanded={false} renders aria-expanded="false" — the correct outcome. Vue 2 removed any attribute whose bound value was false, so :aria-expanded="false" produced no attribute at all; Vue 3 changed that rule so only null and undefined remove it. Angular’s [attr.aria-expanded] renders "false" for false and removes the attribute for null, which is why the condition ? true : null idiom is so common in Angular templates and so dangerous when copied onto a state attribute. Svelte removes an attribute for null and undefined. Across every one of them, undefined removes the attribute, which makes three patterns reliably wrong: aria-expanded={isOpen || undefined}, aria-pressed={props.pressed} where the prop is optional and unset, and object spreads of the form {...(isOpen && { 'aria-expanded': true })} that produce no key at all in the collapsed state. Each of those ships a button that announces its expanded state and then announces nothing when collapsed.
The second cause is timing. Frameworks batch state updates, so the DOM write happens after the event handler returns — in React 18 that is after the automatic batch flushes, in Vue after the microtask that nextTick awaits, in Angular after change detection runs for the zone the event fired in. A test runner’s await button.click() resolves when the browser has dispatched and processed the input event, which is strictly earlier. A raw getAttribute on the next line therefore reads the pre-commit value, and it reads it most of the time, because the gap is one microtask on a fast machine and several frames on a loaded CI runner. That is the exact profile of a test that passes locally and fails one run in eight in the pipeline.
The third cause is architectural. A controlled component derives its state from a prop; an uncontrolled one owns internal state. Components that accept an optional value prop are often quietly both, and then the visual state and the attribute can come from different sources. A disclosure that opens from internal state on click, while aria-expanded renders from a prop the parent only updates after a server round trip, is open on screen and collapsed in the accessibility tree for as long as the request takes. Neither snapshot is invalid on its own — axe sees a well-formed aria-expanded="false" on a button with a valid aria-controls — so no scanner will ever report it. The defect is the disagreement, which means the assertion has to check the pair.
Configuration
Write one helper that asserts a state attribute the only way that survives batching, and use it everywhere. Playwright’s toHaveAttribute is a web-first assertion: it re-reads the DOM until the expectation holds or the timeout expires, which absorbs the render tick without introducing a sleep. The important discipline is that the expected value is a string literal, and that both polarities are asserted, so an attribute that disappears in the false state fails loudly.
// tests/a11y/aria-state.ts — the only sanctioned way to read a state attribute
import { expect, type Locator } from '@playwright/test';
// Every value these attributes may legally carry, per the ARIA specification.
const ALLOWED: Record<string, readonly string[]> = {
'aria-expanded': ['true', 'false'],
'aria-selected': ['true', 'false'],
'aria-checked': ['true', 'false', 'mixed'],
'aria-pressed': ['true', 'false', 'mixed'],
'aria-disabled': ['true', 'false'],
};
export async function expectAriaState(
control: Locator,
attribute: keyof typeof ALLOWED,
expected: string,
) {
if (!ALLOWED[attribute].includes(expected)) {
throw new Error(`${expected} is not a legal ${attribute} value`);
}
// Retries until the batched render commits; never reads the DOM once.
await expect(control).toHaveAttribute(attribute, expected);
}
// Asserts the attribute exists at all, whatever its value. This is the check
// that catches `aria-expanded={isOpen || undefined}` in the collapsed state.
export async function expectAriaStatePresent(
control: Locator,
attribute: keyof typeof ALLOWED,
) {
const value = await control.getAttribute(attribute);
expect(
value,
`${attribute} is absent; an absent state attribute announces nothing`,
).not.toBeNull();
expect(ALLOWED[attribute]).toContain(value!);
}
Then assert the state and its visible consequence together, in both directions, for every toggleable control. The pairing is what catches controlled-component drift: the panel and the attribute have to agree, not merely each be well formed.
// tests/a11y/disclosure-state.spec.ts
import { test, expect } from '@playwright/test';
import { expectAriaState, expectAriaStatePresent } from './aria-state';
test('disclosure state tracks the panel through the render cycle', async ({ page }) => {
await page.goto('/account/billing');
const toggle = page.getByRole('button', { name: 'Payment methods' });
const panelId = await toggle.getAttribute('aria-controls');
expect(panelId, 'aria-controls is required to pair state with content').toBeTruthy();
const panel = page.locator(`#${panelId}`);
// Collapsed: the attribute must be present and false, not absent.
await expectAriaStatePresent(toggle, 'aria-expanded');
await expectAriaState(toggle, 'aria-expanded', 'false');
await expect(panel).toBeHidden();
await toggle.click();
// Expanded: the retrying assertion absorbs the batched attribute write.
await expectAriaState(toggle, 'aria-expanded', 'true');
await expect(panel).toBeVisible();
await toggle.click();
// Back to collapsed, which is where a controlled component drifts: the
// parent may not have propagated the prop that renders the attribute.
await expectAriaState(toggle, 'aria-expanded', 'false');
await expect(panel).toBeHidden();
});
test('tab list moves aria-selected with the visible panel', async ({ page }) => {
await page.goto('/account/billing');
const invoices = page.getByRole('tab', { name: 'Invoices' });
const usage = page.getByRole('tab', { name: 'Usage' });
await expectAriaState(invoices, 'aria-selected', 'true');
await expectAriaState(usage, 'aria-selected', 'false'); // must be false, not absent
await usage.click();
await expectAriaState(usage, 'aria-selected', 'true');
await expectAriaState(invoices, 'aria-selected', 'false');
// Exactly one selected tab: two are possible while a render is half applied.
await expect(page.getByRole('tab', { selected: true })).toHaveCount(1);
});
The pairing assertion generalises into a custom axe check, which is worth doing because it then runs on every scanned page rather than only on the controls a spec happens to drive. axe already validates the value of these attributes through aria-valid-attr-value; what it cannot know is whether the value agrees with the content the control owns.
// a11y/rules/checks/expanded-matches-region.js
// aria-expanded must agree with the visibility of its aria-controls target.
export const expandedMatchesRegion = {
id: 'expanded-matches-region',
metadata: {
impact: 'serious',
messages: {
pass: 'aria-expanded agrees with the controlled region',
fail: 'aria-expanded is "${data.state}" but the region is ${data.visibility}',
incomplete: 'No resolvable aria-controls target; verify this control by hand',
},
},
evaluate: function (node) {
const state = node.getAttribute('aria-expanded');
if (state !== 'true' && state !== 'false') return undefined;
const id = (node.getAttribute('aria-controls') || '').trim().split(/\s+/)[0];
const region = id ? document.getElementById(id) : null;
if (!region) return undefined; // nothing to compare against
// axe's own visibility helper honours display, visibility and hidden.
const visible = axe.commons.dom.isVisibleToScreenReaders(region);
this.data({ state: state, visibility: visible ? 'visible' : 'hidden' });
this.relatedNodes([region]);
return state === 'true' ? visible : !visible;
},
};
Register it against a selector narrow enough that it only sees controls that claim expandable state, and give it the success criterion it enforces. Wiring it into an existing bundle follows the same registration path as any custom rule, and it earns fixture tests for both polarities exactly as described in the guide on unit-testing custom axe rules with Jest fixtures.
// a11y/rules/expanded-rule.js
axe.configure({
checks: [expandedMatchesRegion],
rules: [
{
id: 'expanded-state-agrees',
selector: '[aria-expanded][aria-controls]',
tags: ['wcag2a', 'wcag412', 'custom'], // SC 4.1.2 Name, Role, Value
metadata: {
description: 'aria-expanded must match the state of the controlled region',
help: 'Render the attribute from the same state that renders the panel',
},
all: ['expanded-matches-region'],
},
],
});
Validation
Prove the assertions fail on the real defects before trusting them. Three edits, three distinct failure messages:
npx playwright test tests/a11y/disclosure-state.spec.ts --reporter=list
# 1. aria-expanded={isOpen || undefined} — the attribute vanishes when closed:
# ✘ disclosure state tracks the panel through the render cycle
# aria-expanded is absent; an absent state attribute announces nothing
# Expected: not null Received: null
#
# 2. Attribute rendered from a prop the parent updates after a fetch:
# ✘ disclosure state tracks the panel through the render cycle
# expect(locator).toHaveAttribute("aria-expanded", "true")
# Received: "false" (panel was visible for 1420ms)
#
# 3. Second tab keeps aria-selected while the first one is still selected:
# ✘ tab list moves aria-selected with the visible panel
# expect(locator).toHaveCount(1) Expected: 1 Received: 2
#
# All three fixed:
# ✓ disclosure state tracks the panel through the render cycle
# ✓ tab list moves aria-selected with the visible panel
The custom rule validates the same way from the other side. Run a scan with the disclosure open and confirm expanded-state-agrees lands in passes; then force the drift by holding the prop at false while the panel renders, and confirm the same rule id lands in violations with the region as a related node. A control with no aria-controls should appear in incomplete rather than either array — that is the check declining to guess, and it is the behaviour to verify explicitly, because a check that returns false when it cannot tell will bury the pipeline in findings nobody can act on.
Edge Cases and Conditional Guards
aria-disabledis notdisabled. A control witharia-disabled="true"is still focusable and still receives clicks, which is the point: keyboard users can reach it and hear why it is unavailable. That also meanslocator.click()succeeds and the handler may run, so assert both that the attribute is"true"and that the action did not take effect. A nativelydisabledelement is the opposite trap — Playwright’s actionability check will wait and then time out rather than clicking, so a test written foraria-disabledfails with a confusing timeout when someone swaps in the native attribute.- Tri-state controls cannot be expressed by a boolean.
aria-checkedandaria-pressedboth accept"mixed", which a parent checkbox in a tree or a partially applied formatting button needs. A binding of typebooleancan never produce it, so the component’s state has to be a three-value union and the assertion has to allow"mixed"— which is why the helper validates against a token list per attribute instead of accepting any string. - State inside a shadow root. In a web component the host usually carries the role while the inner control carries the state, or the reverse, and a document-level
getAttributereads whichever one is empty. Assert through a locator that pierces the shadow boundary, and for the rule side resolve the attribute from the element that owns the role rather than the host, using the patterns in writing axe rules for web components and shadow DOM.
Pipeline Impact
These are functional assertions, not scanner findings, so they gate through the test runner’s exit code and appear in the pull request as a failed spec rather than as an axe violation. That has a practical consequence for gating policy: a threshold expressed in violation counts will never block on a state-tracking defect, so the spec file needs to be inside a job that is itself a required status check. Keep it in the same job as the state-matrix scan so one workflow owns the whole dynamic-DOM surface, and name the specs after the component so a failure routes to an owner without triage.
Retries deserve a deliberate decision here. A retry can convert the batched-update race into a green run, which is precisely the information the test exists to surface. If the accessibility project runs with retries: 1 for infrastructure reasons, treat any state assertion that passes only on retry as a defect in the assertion — almost always a raw getAttribute that should have been a retrying expectation — and fix it rather than accepting the flaky-but-green result. The custom rule half of this page, by contrast, does flow into violation counts and impact thresholds, so introducing expanded-state-agrees to an existing codebase will move the numbers; land it in a warning tier first if the pipeline already runs the progressive-threshold pattern.
Common Pitfalls
- Passing a boolean straight into a state attribute without checking what the framework does with
falseandundefined, which silently removes the attribute in exactly the state that needs it most. - Reading the attribute with
getAttributeimmediately after an action instead of using a retrying assertion, producing a test that fails only under CI scheduling pressure. - Asserting only the true state, so a component that never writes
"false"— or writes nothing at all — passes every test in the suite. - Asserting the attribute without asserting the content it describes, which leaves controlled-component drift completely undetected because both halves are individually valid.
- Testing state by simulating the framework’s own event rather than a real user gesture, so a handler bound to
pointerdowninstead ofclickis never exercised the way a keyboard user would exercise it. - Adding a fixed wait before the read to “let the render finish”, which turns a race into a slow race and hides the fact that the component sometimes never commits at all.
FAQ
Should the state assertion live in a component test or an end-to-end test?
Both, for different reasons. A component test is faster, runs on every save, and can drive the state directly, so it is the right place to prove that each of the three legal values renders correctly — including "mixed", which is awkward to reach through the UI. The end-to-end test is the only place that proves the wiring survives the real parent: the prop actually gets updated, the aria-controls id actually resolves, and the batched update actually lands before the user does anything else. Configuring the component-level half depends on the framework’s test renderer, which the guide on configuring axe-core for React and Vue applications covers.
Why not just wait for the framework’s own idle hook instead of retrying the assertion?
Because the hook is framework-specific, version-specific, and often unavailable from outside the application bundle. act() in React and nextTick() in Vue exist inside a component test, not inside a browser driving a production build; Angular’s testability API is closer but still requires the app to expose it. A retrying assertion needs no cooperation from the application, works identically across every framework in a monorepo, and fails with the actual observed value rather than with a timeout on an internal promise.
Does a missing aria-expanded really matter if the panel is right below the button?
Yes, because the button no longer announces that it controls anything. A screen-reader user hears “Payment methods, button”, activates it, and gets no state change announcement and no indication that content appeared elsewhere in the reading order; the only way to discover the panel is to keep navigating and find it by accident. With aria-expanded present, the same user hears “collapsed” before and “expanded” after, which is the whole mechanism WCAG 2.2 SC 4.1.2 (Name, Role, Value) requires for a custom control’s state.
Related
- DOM Inspection for Dynamic Content — the parent guide on settle signals and scanning each interaction state.
- Testing Focus Management After Client-Side Route Changes — the sibling assertion for behaviour that no single snapshot can prove.
- Custom Rule Development & Context-Aware Testing — the section covering custom checks, rule registration and CI gating.