State and its propagation
State is the mechanism through which most compositional failures actually occur, so it needs an explicit model rather than reliance on framework context, a shared store, or incidental DOM.
Five classes of state
The classes are distinguished by who owns the state, not by how it is stored, because ownership is what determines which component is allowed to express it.
- Intrinsic state is owned wholly by the component: expanded, checked, selected, invalid, busy, current. It is expressed in ARIA on the component’s own element.
- Delegated state is owned by the parent and rendered by the child: which tab is current, which step of a process is active, whether the page is in an error state.
- Ambient state is page or application scope: route, locale, theme, reduced motion, forced colours, zoom or reflow band, offline. Every component may read it and none may own it.
- Profile state comes from the user’s capability and preference model. This is the input that selects variants in the first place.
- Derived state is computed from others, as in “the form is submittable”, which no single component can determine.
Most of the focus and announcement failures in this section are, at bottom, a component expressing state it does not own, or two components expressing the same state independently.
Eight propagation rules
- Downward selection. Profile and ambient state flow down and may change which component or variant renders. This must happen at a declared point, not per component, or the page becomes internally inconsistent.
- Upward obligation. A child that cannot satisfy a guarantee raises an unmet obligation to its parent. Unmet obligations aggregate; they do not disappear.
- Uncertainty propagation. Child uncertainty propagates upward unless the parent bounds it with evidence.
- The single-owner rule. For each of focus, keyboard grammar, live-region priority and landmark identity there is exactly one owner per subtree. Ownership is declared, not inferred.
- State-to-semantics mapping. Every state declares its accessible expression: an ARIA attribute, a live-region message, both, or deliberately neither.
- Announcement debouncing. State changes that can fire rapidly declare a coalescing policy, so the announcement queue is not flooded.
- Transition contracts. A state change declares its focus consequence: focus preserved, moved to a named target, or restored to a remembered target.
- Invalidation on override. Overriding markup, roles, identifiers or handlers marks the dependent guarantees suspended, pending retest.
The fourth rule is the one that does the most work. Nearly every focus and announcement failure described in the failure modes is a case of two owners for something that admits only one, and the deliberate part is that ownership is declared. Inferring it from the DOM is how the current situation arose.
A contract sketch
This is a sketch rather than a schema proposal. Its purpose is to test whether the facts above can be expressed at all, in one artefact, for one compound component. A search results region is a good test case because it collides with almost everything: a form, a heading it does not own, a list, optional filters, pagination, and a status message.
component: search-results
kind: compound
afdsVersion: 0.x-draft
contains:
required: [search-form, results-heading, result-list]
optional: [filters, pagination, status-message]
composition:
allowedParents: [main, search-page]
prohibitedDescendants: [main, nested-search-results]
slots:
resultItem:
acceptsRoles: [listitem]
forbidsRoles: [main, navigation, dialog]
semanticOwnership:
heading: { suppliedBy: consuming-page, requiredLevel: contextual }
landmark: { ownedBy: consuming-page }
resultsStatus: { ownedBy: search-results }
focus:
owner: search-results
initial: search-input
afterSubmit: results-heading
onError: first-invalid-field
onPaginate: results-heading
restorePolicy: preserve-user-context
keyboard:
resolver: search-results
escape: close-open-filter-popover-then-defer-to-parent
enter: submit-search
arrowKeys: defer-to-child
announcements:
owner: results-status
priority: polite
coalesceWindow: 500ms
suppressChildAnnouncements: [pagination-status, filter-applied]
layout:
method: intrinsic
minInlineSize: 20rem
reflow:
- filters-before-results
- filters-as-disclosure-below-threshold
minimumTargetSize: token(target.min)
state:
intrinsic: [busy, empty, error]
delegated: [currentPage, appliedFilters]
ambient: [locale, reducedMotion, forcedColors, reflowBand]
mapping:
busy: { aria: "aria-busy", announce: polite, coalesce: true }
empty: { aria: none, announce: polite }
error: { aria: "aria-invalid on field", announce: assertive,
focus: first-invalid-field }
obligations:
author:
- Provide a unique, descriptive page heading
- Provide meaningful result titles distinguishable out of context
- Provide an empty-state message
integrator:
- Preserve declared DOM order
- Do not introduce an independent results live region
- Do not portal the filter popover outside this subtree
evidence:
isolated:
automated: { ruleset: wcag22aa-machine-testable,
result: no-violations } # any conforming rule set
keyboard: pass
realisticPage: required
completeProcess: required
assistiveTech:
- { combo: "NVDA/Firefox", status: pass, date: 2026-08 }
- { combo: "JAWS/Chrome", status: pass, date: 2026-08 }
- { combo: "VoiceOver/Safari", status: unverified }
uncertainties:
- Announcement timing under rapid filter changes on VoiceOver/iOS
- Behaviour when result titles are author-supplied HTMLNote what the record does not say. It does not claim that this component is accessible, nor that it is universally safe to use. It states the conditions under which this selected composition is expected to hold, which obligations sit with the author and the integrator, what has been evidenced and in which environments, and what is not yet known. The VoiceOver line says unverified because it is unverified.
The automated line names a level rather than a product: the machine-testable subset of WCAG 2.2 AA, with no violations at component scope. Which rule set produces that result is deliberately left open. A contract has to outlive the tools that check it, and naming one would both date the record and tie the guarantee to a single vendor’s rule coverage, so that a change of tool would read as a change of contract.
The two uncertainty entries are the part I would defend most strongly. A component that cannot say “I do not know what happens here” will have that gap inherited silently by everything built on top of it.
Composition predicates
For any of this to be more than documentation, the relations have to be machine-checkable. Ten predicates cover the cases in this section.
| Predicate | Example assertion |
|---|---|
| requires | A dialog requires a labelled title and a focus-restoration target |
| contains | A tablist contains tabs, each associated with a tabpanel |
| excludes | An interactive card excludes interactive descendants |
| owns | The page shell owns the primary landmarks |
| delegates | A field delegates label text to the author and owns the association |
| coordinates | A page focus controller coordinates dialogs, routing and validation |
| suppresses | A parent status system suppresses redundant child live regions |
| preserves | A layout transform preserves semantic and focus order |
| invalidates | An override invalidates the guarantees it names |
| requiresRetest | Localisation, virtualisation, slot substitution or behavioural override triggers a defined suite |
These predicates are what turn a design system from documentation into an assembly model a validator can enforce. Whether they belong in the component contract, in a separate composition clause of the package, or in both is still open.
References
- W3C. Accessible Rich Internet Applications (WAI-ARIA) 1.2. TR/wai-aria-1.2
- W3C Web Accessibility Initiative. Understanding Success Criterion 2.5.8: Target Size (Minimum). Understanding/target-size-minimum