Accessibility · ARIA Practice

Mirroring Widget State Into ARIA Attributes Without Drift

Why every interactive custom widget keeps state in two places, which attributes the tree actually reads, and how a render boundary prevents the two copies from disagreeing.

A custom checkbox has a checked state. So does the accessibility tree, and it is not the same copy. The JavaScript object holds a boolean; the tree holds whatever aria-checked last said. Between the moment the user clicks and the moment the attribute is written, the two disagree, and anything that queries the tree in that window gets the old answer. Every ARIA state attribute is a cache of application state maintained by hand, and the discipline of keeping it correct is most of what separates a working custom widget from one that lies to assistive technology at the worst moment.

Two copies of one fact

Native form controls do not have this problem in the same form. A <input type="checkbox"> owns its checkedness inside the element, and the browser derives the accessibility state from that internal value on demand. There is no attribute to keep in sync, because the state and its exposure are the same fact stored once.

A div with role="checkbox" has no internal value. The element’s checkedness exists only in the author’s data model, and the tree learns about it only from aria-checked. The attribute is therefore a projection, not a source. Two consequences follow.

The first is that the projection can be stale. Any code path that mutates the data model without writing the attribute produces a widget whose visible rendering and announced state differ. The failure is silent: the pixels are right, the object is right, and only the tree is wrong, which is precisely the copy no sighted developer inspects.

The second is that the projection is the only thing assistive technology sees. A screen reader does not call into the application’s data model. When it reports “not checked”, it is reporting the attribute’s value, whatever the object says. Arguments about which copy is authoritative are settled by the platform: the tree wins for the user, so the tree must win for the code.

The tri-state, and why the default is absent rather than false

Most boolean ARIA states are not booleans. aria-checked, aria-pressed, and aria-selected accept true, false, mixed, and undefined. The undefined value is the default, and it means something different from false: it means the widget does not support the state at all.

For aria-selected the distinction is load-bearing. A tab inside a tablist reports aria-selected="false" when it is an unselected tab in a widget that has a selection. A tab with no aria-selected at all is a tab in a widget with no concept of selection, which is a different widget. Screen readers announce the difference; a tab strip whose tabs all carry aria-selected="false" and none carry true reads as a list of tabs with nothing current, and the user has no way to know where they are.

mixed is narrower than it looks. It is defined for aria-checked on checkbox and menuitemcheckbox, and for aria-pressed on button, and it is not a valid value for radio or switch. A tri-state checkbox that represents a group of children is the intended use: some children checked, some not. Applying mixed to a radio produces a state the role’s definition does not permit, and implementations differ in whether they coerce it, ignore it, or expose something the role never promised.

aria-expanded follows the same absent-means-unsupported rule, with a further constraint: it belongs on the element that controls the expansion, not on the element being expanded. A disclosure button carries aria-expanded; the panel it reveals carries nothing and is simply removed from the tree when collapsed. Putting aria-expanded on the panel is a common inversion, and it produces a region announced as collapsible that cannot itself be collapsed.

Values with units, and the text override

Range widgets carry four attributes: aria-valuenow, aria-valuemin, aria-valuemax, and aria-valuetext. The first three are numbers and the specification requires the invariant valuemin <= valuenow <= valuemax; a slider that reports aria-valuenow="120" with aria-valuemax="100" is out of contract, and implementations clamp, ignore, or pass the raw number through depending on platform and version. The safer engineering habit is to derive the three values from a single normalised position so the invariant holds by construction rather than by validation.

aria-valuetext replaces the spoken value, not the numeric one. A slider whose range is 0 to 100 and whose meaning is a percentage of volume can expose aria-valuenow="40" for the platform and aria-valuetext="40 percent" for speech, and both stay in the tree. The override exists because a bare number is ambiguous: without it, a screen reader has to guess whether 40 is a count, a percentage, or a position. When aria-valuetext is present, assistive technology announces it instead of the number, and any code that writes the number without also writing the text produces an announcement that changes character depending on which copy the reader prefers.

aria-disabled is not the disabled attribute

The two mechanisms look interchangeable and are not. Setting the disabled attribute on a form control removes the control from the tab order, suppresses click events at the platform level, and matches the :disabled selector. Setting aria-disabled="true" changes one thing: the tree reports the element as disabled. It stays focusable, it still receives clicks, and no CSS state applies.

That difference is the reason aria-disabled exists. A toolbar whose buttons become unavailable must not lose focus when the focused button becomes unavailable, because the user’s place in the widget would be destroyed and there is nowhere for focus to go. Keeping the element focusable while reporting it as disabled preserves the position and tells the user why nothing happens. The cost is that every handler must check the flag, since the platform does not block the event.

The reverse error is equally common: applying the native attribute to a custom widget built from a div, where it has no effect at all because the element is not a form control. The attribute is valid HTML on any element, does nothing, and the widget continues to accept interaction while the author believes it is inert.

The render boundary

The remedy for drift is to make the attributes a function of state rather than a set of statements scattered through event handlers. One function takes the model and writes every exposed attribute, and every mutation goes through it.

const view = { open: false, selected: -1, disabled: false };

function render(el) {
  const trigger = el.querySelector('[data-trigger]');
  trigger.setAttribute('aria-expanded', String(view.open));
  trigger.setAttribute('aria-disabled', String(view.disabled));
  el.querySelector('[role="listbox"]').hidden = !view.open;
  for (const [i, opt] of el.querySelectorAll('[role="option"]').entries()) {
    // every option states its selection, so none is left at the default
    opt.setAttribute('aria-selected', String(i === view.selected));
  }
}

The function has three properties worth naming. It is total: every exposed attribute is written on every call, so a stale attribute cannot survive a transition. It is idempotent in value, writing the same string that is already present, which costs an attribute write but keeps the code free of per-attribute diffing. And it is the only writer, which is the property that matters, because an attribute written from a second place is a second source of truth.

The cost is that a full render writes attributes that did not change. Browsers coalesce accessibility updates within a frame in most cases, so a burst of no-op attribute writes rarely produces a burst of announcements, but the write itself still touches the DOM and, in a component that re-renders on every pointer move, that is measurable work. The usual compromise is to diff at the level of the state object rather than the attribute: recompute all derived attributes, then apply only those whose string value differs.

Failures that survive a correct render

A correct render function still leaves three failure classes.

The first is a state that has no attribute. A widget that visually indicates “current page” with a bold weight and no aria-current gives the tree nothing to report; the fix is a value on the current item (aria-current="page" is the conventional one for navigation) rather than a new role. The aria-current vocabulary is page, step, location, date, time, true, and false, and the specific tokens are preferred over true because they let the reader say what kind of current it is.

The second is a state expressed through two attributes at once. A toggle button can carry aria-pressed or change its own name between “Mute” and “Unmute”; doing both produces a button announced as “Unmute, pressed”, which is contradictory. Choosing one channel per state is the rule, and it is the same rule that governs live regions and focus, discussed in why a live region needs a single owner.

The third is an attribute on an element that the tree does not contain. Any state written to a subtree that is hidden from the tree is invisible until the subtree returns, and if the state changed while it was hidden, the first announcement after the return is wrong. This is the interaction between state and visibility covered in hiding subtrees without breaking focus, and it is the most common way a widget that tests correctly still fails in use.

References

  1. WAI-ARIA 1.2 — W3C
  2. Core Accessibility API Mappings 1.2 — W3C
  3. HTML Living Standard: The disabled attribute — WHATWG