Composite Widget Roles & States
Permalink to "Composite Widget Roles & States"A composite widget is a single interactive control that contains other focusable descendants and manages navigation between them internally — a data grid, a treegrid, a listbox, or a tablist. From the keyboard’s perspective the whole widget is one Tab stop; from the screen reader’s perspective it is an application region where arrow keys belong to your code, not the reading cursor. Get the role, the focus model, and the state attributes right and the widget is fully operable; get any one wrong and it becomes a silent keyboard trap. This page defines the roles used in data UIs, the two focus models you must choose between, and the ARIA states each role obliges you to expose under WCAG 4.1.2 Name, Role, Value (Level A).
Composite widgets sit on top of two foundational patterns. This page assumes you have already implemented roving tabindex for custom data grids and understand keyboard focus trapping and navigation, because a composite widget is where both come together.
WCAG Criteria in Scope
Permalink to "WCAG Criteria in Scope"| Criterion | Level | Relevance to this pattern |
|---|---|---|
| 1.3.1 Info and Relationships | A | The role hierarchy (grid → row → gridcell) must be programmatically exposed, not implied visually |
| 2.1.1 Keyboard | A | Every descendant must be reachable and operable using the arrow-key contract, with one Tab stop for the widget |
| 2.1.2 No Keyboard Trap | A | Tab and Shift+Tab must exit the widget; only arrow keys navigate within it |
| 2.4.3 Focus Order | A | The active-item pointer (real or virtual) must track a logical, predictable order |
| 2.4.7 Focus Visible | AA | The active descendant must show a visible focus indicator even when DOM focus is on the container |
| 4.1.2 Name, Role, Value | A | Roles, aria-selected, aria-expanded, aria-multiselectable, and aria-activedescendant must reach the accessibility API |
Prerequisites
Permalink to "Prerequisites"This page builds directly on two others:
- Implementing roving tabindex for custom data grids — the first of the two focus models; you need it before the
aria-activedescendantalternative makes sense. - Keyboard focus trapping and navigation — the difference between a Tab stop and a keyboard trap, which every composite widget must respect (SC 2.1.2).
You should also understand how aria-live regions for dynamic data announce state changes, because selection-count and expand/collapse events in a composite widget often need a status region as well as an ARIA state.
The composite roles used in data UIs
Permalink to "The composite roles used in data UIs"Four composite roles cover almost every data interface. Each defines a required parent/child role structure that the accessibility tree checks — omit a required child role and the widget’s semantics collapse.
| Role | Use when | Required descendant roles | Selection model |
|---|---|---|---|
grid |
Tabular data needs arrow-key cell navigation (spreadsheet-like) | row → gridcell (and columnheader / rowheader) |
aria-selected on cells or rows |
treegrid |
Tabular data with expandable, hierarchical rows | row (with aria-expanded) → gridcell |
aria-selected + aria-expanded |
listbox |
A single-column pick list, one or many selections | option (optionally grouped in group) |
aria-selected; aria-multiselectable on the container |
tablist |
Switching between panels of related data | tab → controls a tabpanel |
aria-selected on the active tab |
Rule of thumb: do not apply a composite role to a static or reading-first table. A native <table> with sortable headers works in reading mode and needs no composite role — see the sortable and filterable data grids cluster for that path. Reach for role="grid" only when cell-by-cell arrow navigation is the primary interaction. For expandable hierarchies, the building an accessible treegrid with expandable rows topic covers the treegrid variant in depth.
Application mode: why composite roles change everything
Permalink to "Application mode: why composite roles change everything"The single most important consequence of a composite role is invisible in the DOM: it forces assistive technology out of reading mode (also called browse or virtual-cursor mode) and into application mode (focus or forms mode).
In reading mode, the screen reader owns the arrow keys. It moves a virtual cursor through the page, and users rely on quick-nav keys (H for headings, T for tables, K for links). In application mode, the screen reader passes keystrokes straight through to your JavaScript. Arrow keys no longer move the virtual cursor — they do whatever your keydown handler decides.
This is the deal a composite role makes: you gain a rich keyboard model, but you take on the obligation to implement all of it. NVDA and JAWS switch to application mode automatically when focus enters an element with role="grid", role="listbox", or similar. If your handler does nothing for ArrowDown, the user is stranded — the reading cursor is disabled and your code provides no replacement. That is the classic composite-widget keyboard trap, and it fails WCAG 2.1.1 Keyboard and 2.1.2 No Keyboard Trap.
The two focus models
Permalink to "The two focus models"Every composite widget must adopt exactly one of two focus models. This choice governs where DOM focus lives and how the active item is tracked. The decision tree below drives the rest of the implementation.
Model A — roving tabindex
Permalink to "Model A — roving tabindex"Real DOM focus moves. Exactly one descendant has tabindex="0" (the active item); every other descendant has tabindex="-1". Arrow keys change which item holds the 0 and call .focus() on it. The browser’s own focus ring marks the active item, and the screen reader announces the newly focused element directly. This is the default recommendation and is fully covered in implementing roving tabindex for custom data grids.
Model B — aria-activedescendant
Permalink to "Model B — aria-activedescendant"DOM focus never leaves the container. The container carries tabindex="0" and aria-activedescendant set to the id of the active descendant. Arrow keys rewrite that id; the descendants themselves are never focused and carry no tabindex. Because the browser draws no focus ring on an element that does not hold DOM focus, you must style the active descendant yourself to satisfy SC 2.4.7. This model shines when items are virtualized or added and removed frequently, since there is no DOM focus to lose when a node is recycled. The full implementation is in the deep-dive: using aria-activedescendant for grid cell focus.
Do not mix the two. A widget with both a roving tabindex and an aria-activedescendant pointer exposes two competing “active item” signals; the visible ring and the screen-reader announcement drift apart.
ARIA state reference (attribute by attribute)
Permalink to "ARIA state reference (attribute by attribute)"aria-selected
Permalink to "aria-selected" Boolean state on selectable descendants (option, row, gridcell, tab). It communicates selection independently of focus — an item can be focused but not selected, or selected but not focused. Set aria-selected="true" on selected items and aria-selected="false" on selectable-but-unselected items in the same widget. Omitting it entirely on a listbox option tells AT the option is not selectable.
aria-multiselectable
Permalink to "aria-multiselectable" Boolean state on the container (grid, listbox, treegrid). aria-multiselectable="true" tells AT the user may select more than one descendant, which changes how NVDA and VoiceOver announce selection (“selected” vs “1 of 3 selected”). Pair it with a keyboard model that supports range selection (Shift+Arrow) and additive selection (Ctrl/Cmd+Space).
aria-expanded
Permalink to "aria-expanded" Boolean state on a row in a treegrid, or on a control that shows/hides a region. true means the row’s children are visible; false means collapsed. A row that can never expand must not carry the attribute at all — an always-present aria-expanded="false" announces a disclosure affordance that does not exist.
aria-activedescendant
Permalink to "aria-activedescendant" Relationship property on the focused container. Its value is the id of the currently active descendant. It is only meaningful under focus model B. The referenced element must exist in the DOM — a dangling reference (common with virtualization) leaves AT with no active item and produces silence. See the aria-activedescendant deep-dive for the virtualization caveat.
Step-by-Step Implementation
Permalink to "Step-by-Step Implementation"The example builds a single-select role="grid" using roving tabindex. Swap in aria-activedescendant per the deep-dive if your rows are virtualized.
Step 1 — Declare the role hierarchy (WCAG 1.3.1)
Permalink to "Step 1 — Declare the role hierarchy (WCAG 1.3.1)"Every composite role requires specific child roles. A grid needs row and gridcell; skipping the row level breaks the accessibility tree.
<!-- WCAG 1.3.1: role hierarchy grid → row → gridcell exposes structure -->
<!-- WCAG 4.1.2: aria-multiselectable declares the selection model on the container -->
<div
role="grid"
aria-label="Open invoices"
aria-multiselectable="false"
aria-rowcount="4"
tabindex="0"
id="invoice-grid"
>
<div role="row" aria-rowindex="1">
<!-- columnheader is a required header role inside the grid -->
<div role="columnheader">Invoice</div>
<div role="columnheader">Amount</div>
</div>
<div role="row" aria-rowindex="2">
<!-- WCAG 4.1.2: aria-selected exposes selection state per cell -->
<div role="gridcell" id="c-2-1" tabindex="0" aria-selected="false">INV-3391</div>
<div role="gridcell" id="c-2-2" tabindex="-1" aria-selected="false">£1,240.00</div>
</div>
</div>
Step 2 — Choose and wire the focus model (WCAG 2.4.3, 2.4.7)
Permalink to "Step 2 — Choose and wire the focus model (WCAG 2.4.3, 2.4.7)"With roving tabindex, exactly one cell holds tabindex="0". Arrow keys move the 0 and call .focus().
// WCAG 2.4.3 Focus Order: arrow keys move the active cell in a logical order
// WCAG 2.4.7 Focus Visible: the browser draws the ring on the focused cell
const grid = document.getElementById('invoice-grid');
const cells = () => Array.from(grid.querySelectorAll('[role="gridcell"]'));
function moveActive(from, to) {
from.setAttribute('tabindex', '-1');
to.setAttribute('tabindex', '0'); // roving tabindex: only one 0 at a time
to.focus(); // real DOM focus moves → visible ring + AT announce
}
Step 3 — Implement the arrow-key contract (WCAG 2.1.1, 2.1.2)
Permalink to "Step 3 — Implement the arrow-key contract (WCAG 2.1.1, 2.1.2)"Application mode means these keys are yours. Handle every direction plus Home/End, and leave Tab alone so focus can exit (SC 2.1.2).
// WCAG 2.1.1 Keyboard: full internal navigation from the single grid Tab stop
// WCAG 2.1.2 No Keyboard Trap: Tab is NOT handled, so it exits the widget
grid.addEventListener('keydown', (e) => {
const all = cells();
const current = document.activeElement;
const i = all.indexOf(current);
if (i === -1) return;
const cols = 2; // cells per row in this grid
let next = null;
switch (e.key) {
case 'ArrowRight': next = all[i + 1]; break;
case 'ArrowLeft': next = all[i - 1]; break;
case 'ArrowDown': next = all[i + cols]; break;
case 'ArrowUp': next = all[i - cols]; break;
case 'Home': next = all[i - (i % cols)]; break;
case 'End': next = all[i - (i % cols) + (cols - 1)]; break;
default: return; // Tab, Shift+Tab, etc. pass through → no trap
}
if (next) { e.preventDefault(); moveActive(current, next); }
});
Step 4 — Toggle selection state (WCAG 4.1.2)
Permalink to "Step 4 — Toggle selection state (WCAG 4.1.2)"Space toggles aria-selected on the active cell. The state is separate from focus, so an unselected cell can still be the focused one.
// WCAG 4.1.2 Name, Role, Value: aria-selected reflects the current selection
grid.addEventListener('keydown', (e) => {
if (e.key !== ' ') return;
const cell = document.activeElement;
if (cell.getAttribute('role') !== 'gridcell') return;
e.preventDefault();
const selected = cell.getAttribute('aria-selected') === 'true';
cell.setAttribute('aria-selected', String(!selected));
});
Step 5 — Announce selection changes via a status region (WCAG 4.1.3)
Permalink to "Step 5 — Announce selection changes via a status region (WCAG 4.1.3)"aria-selected reaches AT on focus, but a running selection count needs a live region. Keep it polite so it never interrupts navigation.
<!-- WCAG 4.1.3 Status Messages: count reaches AT without moving focus -->
<div role="status" aria-live="polite" aria-atomic="true" class="sr-only" id="grid-status"></div>
// WCAG 4.1.3: report the selection count after each toggle
function announceCount() {
const n = grid.querySelectorAll('[aria-selected="true"]').length;
const region = document.getElementById('grid-status');
region.textContent = '';
requestAnimationFrame(() => { region.textContent = `${n} row${n !== 1 ? 's' : ''} selected`; });
}
Keyboard Interaction Contract
Permalink to "Keyboard Interaction Contract"| Key | Context | Action | Expected AT announcement | Failure indicator |
|---|---|---|---|---|
Tab |
Outside the widget | Move focus onto the widget (one stop) | “Open invoices, grid” | Focus lands on every cell individually (roving not applied) |
Tab |
Inside the widget | Exit to next page control | Next control’s name and role | Focus is trapped inside the grid (SC 2.1.2 failure) |
Arrow keys |
On a gridcell | Move active cell in that direction | “INV-3391, column 1” | Silence — application mode active but no handler |
Home / End |
On a gridcell | Move to first / last cell in the row | First / last cell’s contents | Cursor jumps to page start (reading mode not suppressed) |
Space |
On a selectable cell | Toggle aria-selected |
“selected” / “not selected” | Icon changes but AT is silent (state not on the element) |
Enter |
On a tab (tablist) | Activate the tab, show its panel | “selected, tab 2 of 4” | Panel switches but aria-selected stale |
Screen Reader Compatibility Matrix
Permalink to "Screen Reader Compatibility Matrix"| AT + Browser | Application-mode switch | Active-item tracking | Known deviation |
|---|---|---|---|
| NVDA 2024 + Firefox | Enters focus mode on role="grid"; announces “grid” |
Follows both roving focus and aria-activedescendant |
May stay in browse mode if focus enters via the virtual cursor; press Enter to force focus mode |
| NVDA 2024 + Chrome | Enters focus mode reliably | Tracks aria-activedescendant id changes |
Occasionally re-reads the row context on every cell move; keep cell names concise |
| JAWS 2024 + Chrome | Switches to forms mode automatically | Announces aria-selected and column position |
Multi-select counts depend on aria-multiselectable being on the container, not the rows |
| VoiceOver + Safari (macOS) | Treats grid as an interactable; use VO+arrows or Quick Nav off |
Tracks aria-activedescendant; needs the referenced node present |
Announces “selected” only when aria-selected is explicitly true/false, not absent |
| TalkBack + Chrome (Android) | Explore-by-touch reads cells; swipe navigation uses your handlers | Tracks focus model B less reliably; roving tabindex is safer | Prefer roving tabindex for touch — see how TalkBack differs for data grid navigation |
Edge Cases & Failure Modes
Permalink to "Edge Cases & Failure Modes"1. Composite role with no keyboard handler
Permalink to "1. Composite role with no keyboard handler"The most common failure: a developer adds role="grid" for “better semantics” but implements no arrow-key handler. AT switches to application mode, disables its own arrow navigation, and the user is trapped with no way to move. Either implement the full contract or remove the composite role and use a native table.
2. aria-activedescendant pointing at a removed node
Permalink to "2. aria-activedescendant pointing at a removed node" Under focus model B with virtualization, scrolling can recycle the DOM node whose id the container references. The pointer now dangles and AT reports no active descendant. Always update aria-activedescendant to a node that is present in the DOM, and keep the active row rendered even when it scrolls out of view — see the deep-dive’s virtualization caveat.
3. aria-selected on the wrong element
Permalink to "3. aria-selected on the wrong element" Placing aria-selected on a wrapper <div> instead of the element carrying the gridcell / option / row role means AT never associates the state with the item. The attribute must live on the same element as the role.
4. Missing aria-multiselectable in a multi-select widget
Permalink to "4. Missing aria-multiselectable in a multi-select widget" If the widget supports Ctrl+Space additive selection but the container lacks aria-multiselectable="true", screen readers announce each selection as if it replaced the previous one. Users cannot tell that earlier selections persist.
5. Nested interactive controls stealing Tab
Permalink to "5. Nested interactive controls stealing Tab"A gridcell that contains a real <button> or <a> re-introduces extra Tab stops inside a widget that should be one stop. Manage inner controls with the same roving pattern (the “actions in a cell” problem), or move them out of the composite widget.
Deep-dive: managing the active descendant by id
Permalink to "Deep-dive: managing the active descendant by id"When you adopt focus model B, the mechanics of maintaining the aria-activedescendant pointer become the entire implementation: generating stable ids, rewriting the pointer on every arrow key, scrolling the active cell into view, and keeping the referenced node alive through virtualization. Those details — including annotated HTML and JavaScript and the exact scroll-into-view sequence — are in the dedicated reference: using aria-activedescendant for grid cell focus.
Key facts from that page:
- The container is the only element with
tabindex="0"; descendants carry notabindex. - You must style the active descendant yourself, because the browser draws no focus ring on it (SC 2.4.7).
- The referenced
idmust exist in the DOM at all times, which constrains how aggressively you virtualize.
Testing Checklist
Permalink to "Testing Checklist"Automated
Permalink to "Automated"Keyboard-only
Permalink to "Keyboard-only"Screen reader manual
Permalink to "Screen reader manual"Related
Permalink to "Related"- Using aria-activedescendant for grid cell focus — the container-focus model in full, with the virtualization caveat
- Implementing roving tabindex for custom data grids — the alternative focus model this page contrasts with
- Keyboard focus trapping and navigation — the Tab-stop versus keyboard-trap distinction every composite widget must respect
- Building an accessible treegrid with expandable rows — the
treegridcomposite role applied to hierarchical data - Sortable & filterable data grids — when a native table beats a composite role
- How TalkBack differs for data grid navigation — touch-AT behaviour for composite widgets