ARIA composite widgets: APG patterns are keyboard contracts
One Tab stop per composite; arrows move inside — roving tabindex (real focus, the 0/-1 dance) or aria-activedescendant (DOM focus stays on the input). ARIA state must derive from the same state as the pixels or the widget lies to AT. For combobox-grade widgets, buy headless.
The dispatch console redesign shipped on a Friday; the sprint review three weeks later had one ugly slide: mean call-handle time up 9%. The culprit was a dropdown. The old screen used a native select for carrier assignment — an operation agents perform around two hundred times a shift, almost always by keyboard: Tab into it, type “ma” to jump to Maersk, Enter, Tab on. The redesign replaced it with a hand-rolled styled dropdown, and the keyboard contract quietly died. Every one of the fifty options got tabIndex of 0, so crossing the widget cost fifty Tab presses; arrow keys did nothing; type-ahead did not exist. The agent who used JAWS had it worse: the trigger had aria-expanded="false" hardcoded as a string literal, so the dropdown visually opened while announcing “collapsed” — the widget lied, and she learned to count clicks blind. Nobody on the team had tested with a keyboard, because the mouse path worked and the visual diff was approved. The fix took two sprints, most of it spent discovering that a dropdown is not a styled div but a protocol: WAI-ARIA Authoring Practices defines exactly which keys do what in a listbox, and fifty thousand screen-reader users have those keystrokes in muscle memory. Break the protocol and you have not built a custom widget — you have built a trap that looks like one.
A pattern is a key map, not a look
The WAI-ARIA Authoring Practices Guide (APG) reads like a recipe book, but what it actually defines is a set of contracts: for each widget pattern — tabs, listbox, combobox, dialog, menu — the exact roles, the required state attributes, and the precise keyboard map. Tabs: tablist containing tab elements, aria-selected on the active one, Left/Right arrows to move, Home/End to jump. Listbox: listbox containing option elements, aria-selected, Up/Down, type-ahead. Combobox: an input with role="combobox", aria-expanded, a popup listbox, Down to open and walk, Escape to dismiss. The contract exists because assistive-technology users learn the pattern once and replay it everywhere — when a screen reader announces “tab list”, the user already knows arrows will move between tabs. Implement the look without the key map, and the announcement makes a promise your widget breaks: worse than no pattern, because it weaponizes the user’s training against them.
The structural rule underneath every composite pattern: one Tab stop per widget. A composite — a tab list, a listbox, a grid, a menu — is one thing in the page’s Tab sequence. Tab enters it, Tab leaves it, and arrow keys navigate inside. The dispatch console broke exactly this: fifty options with tabIndex={0} turned one widget into fifty Tab stops, and the page’s keyboard topology became a swamp. The rule is also the reason composites need engineering at all: HTML’s default is that every focusable element joins the Tab order, so making fifty things act as one requires actively managing which element is focusable at any moment. There are exactly two sanctioned mechanisms.
A code review for a custom tabs component shows every tab button rendered with tabIndex={0}. Mouse and visual behavior are flawless. What is the consequence for a keyboard user, per the APG tabs contract?
Roving tabindex vs aria-activedescendant
Roving tabindex keeps real DOM focus on the active item and moves it with the arrows. Exactly one item in the composite has tabIndex={0} (the active one); every other item has tabIndex={-1}. Tab therefore enters the composite at the active item. On ArrowRight: update React state, re-render flips the tabindex pair, and call focus() on the newly active item’s DOM node — you need a ref per item, and the focus call must happen after the commit (the previous lesson’s flushSync territory if you drive it from state). The payoff: it is real focus — :focus-visible styling just works, document.activeElement tells the truth, and screen readers track it natively.
function Tabs({ tabs, active, onSelect }) {
const refs = useRef(new Map());
const onKeyDown = (e) => {
const delta = e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1 : 0;
if (!delta) return;
const next = (active + delta + tabs.length) % tabs.length;
onSelect(next);
refs.current.get(next)?.focus(); // real focus follows the state
};
return (
<div role="tablist" onKeyDown={onKeyDown}>
{tabs.map((t, i) => (
<button
key={t.id}
role="tab"
aria-selected={i === active}
tabIndex={i === active ? 0 : -1}
ref={(el) => refs.current.set(i, el)}
>
{t.label}
</button>
))}
</div>
);
}aria-activedescendant inverts the model: DOM focus never moves. It stays on the container (for a combobox, on the text input), and the container carries aria-activedescendant={activeOptionId} pointing at the visually highlighted item. The screen reader announces the referenced element as if it were focused — virtual focus. You manage everything focus gave you for free: the highlight styling (no :focus to hook), scrolling the active option into view, and stable, unique ids on every option. The decisive use case is the combobox: the user is typing, so DOM focus must stay in the input while arrows walk the suggestion list — moving real focus away would end the typing session. Roving tabindex physically cannot express “focus here, highlight there”; activedescendant exists for exactly that split.
The tradeoff table: roving tabindex gives real focus events, free focus styling, and the platform’s scroll-into-view on focus — at the cost of a re-render plus focus() call per arrow press and refs plumbing. Activedescendant gives a stationary focus (mandatory for typed inputs, cheaper per keystroke — one attribute change) — at the cost of hand-rolled highlight, hand-rolled scrolling, an id discipline, and historically less uniform AT handling, which is why APG itself uses it mainly where it is structurally required. Default to roving tabindex for tabs, menus, radio groups, toolbars; reach for activedescendant when focus must stay planted — combobox, searchable select, command palette.
The ARIA must not lie, and when to buy instead of build
Every state attribute in the contract — aria-expanded, aria-selected, aria-activedescendant, aria-checked — must be derived from the same React state that drives the visuals. The dispatch console’s aria-expanded="false" string literal is the canonical lie: the popup’s rendering was conditional on open, the attribute was not, and the two truths diverged on the first click. In JSX the fix is mechanical — aria-expanded={open} (React serializes the boolean to the string AT expects) — but the discipline is architectural: one state variable, two projections (pixels and ARIA), zero hand-synchronized copies. A widget whose visual state and announced state disagree is worse than an unlabeled div, because AT users act on the announcement: “collapsed” means “press Enter to open”, and pressing Enter on an already-open combobox closes it — the user and the widget now disagree about reality and every subsequent keystroke compounds the error.
Your team is building a searchable select: the user types in an input to filter options and walks suggestions with Up/Down without interrupting typing. Which focus mechanism does the contract force, and why?
So: build or buy? Honest accounting for a combobox built to the APG contract: the full key map (printable type-ahead, Down/Up/Home/End/Escape/Enter, Alt+Down variants), activedescendant wiring, scroll management, id discipline, focus restoration on dismiss, and then the part nobody budgets — verification across the AT matrix (NVDA and JAWS on Windows, VoiceOver on macOS and iOS, TalkBack on Android, each times two or three browsers). That is weeks of specialist work for one widget, and it explains the existence of headless component libraries: Radix UI, React Aria, Headless UI ship the contract — roles, state attributes, key maps, focus management — with zero visual opinions, leaving styling to you. The senior call is asymmetric: take the library for combobox, select, menu, dialog, and date picker — the patterns where the contract is deep and the AT matrix is unforgiving; hand-roll the shallow ones — tabs is a reasonable first build, a disclosure even more so — when you need to own the pattern or the dependency cost is unjustifiable. The honest costs of buying: a dependency you must keep updated, the library’s composition opinions in your component API, and bundle weight; the honest cost of building: the contract above, forever, including every browser-AT regression that ships after you do.
▸Why this works
Why does the platform make composite widgets this hard — why is there no native tablist element the way there is a native select? Partly history: ARIA was specified to retrofit desktop-application idioms (tab lists, tree views, menu bars) onto a document platform that never had them, so the contracts live in a spec layer rather than in elements. Partly economics: the patterns that did get native elements (select, dialog, details) took years to become stylable enough that teams stopped rebuilding them — select only grew real styling hooks recently. The honest reading for application teams: the platform’s composite-widget gap is being closed slowly, element by element, and until it closes, APG plus a headless library is the contract-compliant substitute for the native elements that do not exist yet.
- 01Contrast roving tabindex and aria-activedescendant: mechanism, costs, and the case where the choice is forced.
- 02Why is a widget whose ARIA state disagrees with its visual state worse than no ARIA, and what is the architectural rule that prevents it?
An APG pattern is a keyboard contract, not a visual recipe: roles announce the pattern, state attributes carry its truth, and the key map is what fifty thousand assistive-technology users already have in muscle memory — implementing the look without the keys makes the announcement a broken promise. Composites obey one structural rule: one Tab stop per widget, arrows inside. Two mechanisms implement it. Roving tabindex keeps real DOM focus on the active item — exactly one tabIndex of zero, the rest minus one, arrows swap the pair and call focus() after the commit — buying real focus events and free focus styling at the price of refs and per-keystroke focus calls. Aria-activedescendant keeps DOM focus stationary on the container and points a virtual focus at the highlighted option’s id — mandatory where typing must continue (combobox, command palette), at the price of hand-rolled highlight, scrolling, and id discipline. State attributes must be projections of the same React state that renders the pixels — aria-expanded={open}, never a hardcoded string — because a widget that renders open while announcing collapsed sends its user into compounding disagreement with reality. And the build-vs-buy line is drawn by contract depth: combobox-grade patterns carry weeks of key-map and AT-matrix work, which is what headless libraries (Radix, React Aria, Headless UI) sell solved with zero visual opinions; shallow patterns like tabs are a legitimate hand-roll. The dispatch console paid 9% of handle time to relearn this: a widget is its protocol, and the protocol is testable only from the keyboard. Now when you review a custom dropdown or tab list, your first question is not “does it look right?” but “does Tab enter once, do arrows move inside, and does every state attribute derive from the same variable that renders the pixels?”
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.
Apply this
Put this lesson to work on a real build.