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:

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) rowgridcell (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.

Composite role and focus-model decision tree Top half: a decision tree. Is arrow-key cell navigation primary? If no, use a native table. If yes, is the data hierarchical? Treegrid if yes, grid if no. Then: are items dynamically added or removed, or virtualized? If yes, prefer aria-activedescendant; if no, roving tabindex is fine. Bottom half: the aria-activedescendant model — DOM focus stays on the grid container, and aria-activedescendant on the container points by id to the active cell, which shows the visible focus ring. 1 · Choose role and focus model Is arrow-key cell navigation the primary interaction? NO Native <table> no composite role YES Hierarchical / expandable rows? role=treegrid vs role=grid Rows virtualized, or added / removed dynamically? NO Roving tabindex tabindex 0 / -1 YES activedescendant pointer by id 2 · The aria-activedescendant focus model <div role="grid" tabindex="0"> DOM focus stays HERE aria-activedescendant="cell-r4c2" cell-r4c1 cell-r4c2 cell-r4c3 The container keeps focus; the id pointer moves. Arrow keys rewrite the id and move the visible ring (thick box) without moving real DOM focus. Referenced id MUST exist in the DOM.

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 no tabindex.
  • You must style the active descendant yourself, because the browser draws no focus ring on it (SC 2.4.7).
  • The referenced id must 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"

Permalink to "Related"

Back to Core ARIA & Keyboard Navigation for Data UIs