Angular CDK Tree Accessibility for Nested Data
Permalink to "Angular CDK Tree Accessibility for Nested Data"The Angular CDK’s tree gives you a data model, expansion state and node rendering — and none of the ARIA that makes a nested data view navigable. This page covers the layer you add on top: the row-level attributes derived from the CDK’s own node objects, the announcement that has to come after change detection, and the trackBy that keeps expansion from destroying focus.
It is the Angular route into expandable rows and nested data, and it implements the pattern described in building an accessible treegrid with expandable rows.
Spec reference
Permalink to "Spec reference"The treegrid pattern requires each row to state its own depth and expansion state, because DOM nesting alone communicates nothing to assistive technology when rows are siblings in a flat table body. aria-level is a 1-based integer; aria-expanded is true or false and must be omitted entirely on leaf rows, because a leaf that says aria-expanded="false" is announced as collapsed content the user can open.
aria-posinset and aria-setsize describe position among siblings at the same level. They are optional in a fully rendered tree — the browser can count siblings — and required as soon as any part of the tree is virtualized or lazily loaded, because then the DOM no longer contains the full sibling set.
aria-rowcount on the container states the number of rows that could be shown at the current expansion state. It changes on every expand and collapse.
When to use — and when not to
Permalink to "When to use — and when not to"Use the CDK tree where the hierarchy is genuinely part of the data: an org chart, a category tree, a file listing, a chart of accounts. The CDK’s flat and nested data sources both work; the flat one is easier to make accessible because the rendered rows are siblings and aria-level carries the depth explicitly.
Do not use a tree where the nesting is a presentation choice over flat data. A grouped table — rows grouped under a header — is better served by rowgroup semantics and a section header row than by an expandable tree.
Do not use role="tree" for tabular nested data. A tree of single-value nodes is a tree; rows with several columns are a treegrid, and the difference determines whether a screen reader offers cell navigation.
Annotated code example
Permalink to "Annotated code example"<!-- SC 1.3.1: the container declares the pattern and the true row count -->
<cdk-tree [dataSource]="dataSource" [treeControl]="treeControl"
role="treegrid" [attr.aria-rowcount]="visibleRowCount">
<cdk-tree-node *cdkTreeNodeDef="let node"
role="row"
[attr.aria-level]="node.level + 1"
[attr.aria-expanded]="node.expandable ? treeControl.isExpanded(node) : null"
[attr.aria-posinset]="node.posInSet"
[attr.aria-setsize]="node.setSize"
tabindex="-1">
<span role="gridcell">
<!-- SC 4.1.2: the button names the ACTION; the row carries the STATE -->
<button type="button"
*ngIf="node.expandable"
cdkTreeNodeToggle
[attr.aria-label]="(treeControl.isExpanded(node) ? 'Collapse ' : 'Expand ') + node.name"
(click)="onToggle(node)">
<span aria-hidden="true">{{ treeControl.isExpanded(node) ? '▾' : '▸' }}</span>
</button>
{{ node.name }}
</span>
<span role="gridcell">{{ node.total | currency }}</span>
</cdk-tree-node>
</cdk-tree>
// SC 4.1.3: announce after change detection has rendered the child rows
constructor(private live: LiveAnnouncer) {}
onToggle(node: FlatNode): void {
const expanding = !this.treeControl.isExpanded(node);
// The CDK toggles the model; we describe the outcome
queueMicrotask(() => {
const added = this.treeControl.getDescendants(node)
.filter((d) => d.level === node.level + 1).length;
this.live.announce(
expanding
? `${node.name} expanded, ${added} rows added`
: `${node.name} collapsed`,
'polite');
});
}
// Stable identity keeps the focused row alive across expand and collapse
trackByNode = (_: number, node: FlatNode) => node.id;
LiveAnnouncer is the CDK’s own polite status region. Using it rather than adding a second live region avoids the double-announcement problem entirely, and it is already in the DOM at bootstrap — which is the timing requirement described in creating live regions before content changes.
Keyboard & AT behaviour
Permalink to "Keyboard & AT behaviour"| Key | Context | Expected result | Failure indicator |
|---|---|---|---|
| Right arrow | Collapsed row | Expand it | Nothing happens — the CDK does not bind arrows for you |
| Right arrow | Expanded row | Move into the first child | Focus stays on the parent |
| Left arrow | Expanded row | Collapse it | Focus jumps to the top of the tree |
| Left arrow | Child row | Move to the parent | Focus is lost when the parent collapses |
| Enter | Row | Activate the row’s primary action | Enter also toggles expansion — an overloaded key |
The CDK does not implement the arrow-key model for you. cdkTreeNodeToggle handles clicks and Enter on the toggle control; the directional keys that make a treegrid navigable are yours to bind, using the roving model from implementing roving tabindex for custom data grids.
Integration context
Permalink to "Integration context"Collapse is a focus event before it is a visual one. If focus is on a descendant when its ancestor collapses, move focus to the ancestor row first, then collapse — otherwise the focused element is removed and the reader falls silent. That ordering is the same one described in the parent guide, and it applies identically in Angular.
If the tree is also virtualized with cdk-virtual-scroll-viewport, aria-posinset and aria-setsize stop being optional, because the DOM no longer contains all siblings. Angular CDK virtual scroll accessibility covers the counting rules.
Gotchas
Permalink to "Gotchas"aria-expanded on leaf rows. Binding it unconditionally produces aria-expanded="false" on rows with no children, which readers announce as collapsed content. Use null for leaves so the attribute is removed.
Nested nodes with deep DOM nesting. The nested tree node renders children inside the parent’s outlet, which produces DOM nesting. That is valid for role="tree", but in a treegrid rows must be siblings — use the flat data source.
aria-rowcount frozen at the initial count. It must change with every expansion. Recompute it from the visible node list.
Announcing during the toggle. The message is composed before Angular renders the children, so the count is wrong. Defer with queueMicrotask or an afterNextRender callback.
FAQ
Permalink to "FAQ"Does the Angular CDK tree add ARIA automatically?
Only partially. The CDK manages the data model, the expansion state and the rendering of child nodes; it does not set aria-level, aria-expanded, aria-posinset or aria-setsize for you, and it does not announce anything. Treat the CDK as the structure layer and add the semantics on top — the attributes are all derivable from the node objects the CDK already gives you.
Where does aria-expanded belong — the row or the toggle button?
On the row. The row is the thing that expands; the button is the control that toggles it. Putting aria-expanded on the button means a user traversing rows never hears the state, because they are not on the button. If the button also needs a state, give it an accessible name that includes the action: “Expand Europe”.
Why does focus jump after expanding a node?
Almost always a missing trackBy. Without it, Angular re-creates row elements when the rendered node list changes, so the focused row is destroyed and replaced by an identical-looking new element. Supply trackBy returning a stable node id and the focused row survives expansion and collapse.
Related
Permalink to "Related"- Expandable rows & nested data — the parent guide and the treegrid model
- Building an accessible treegrid with expandable rows — the framework-agnostic implementation
- Managing route change focus in Angular and Vue — the other Angular focus discipline