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.

What the CDK gives you and what it leaves to you Hierarchy of an Angular CDK tree showing the structural directives the library provides and the ARIA attributes the application must supply on each level. What the CDK gives you and what it leaves to youcdk-tree / role="treegrid"you set role and aria-rowcountcdk-nested-tree-nodeyou set aria-level, aria-expandedcdkTreeNodeTogglethe CDK toggles; you announce theresultcdkTreeNodeOutletchildren are projected here — keepDOM order visual order
The CDK manages the data model; the ARIA on each node is still yours.

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.

Announcing an expansion in Angular Three-step sequence: the toggle updates the CDK data source, Angular renders the child rows, then an announcer writes the message after change detection. Announcing an expansion in AngularToggle firestreeControl.toggle(node)The data source expands the nodeRows renderchild rows appear in the outletaria-expanded flips on the parentAnnounceliveAnnouncer.announce("3 rows added under Europe")After change detection, not during
LiveAnnouncer is the CDK's own status region — use it rather than adding a second one.

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.

CDK tree pitfalls and their symptoms Matrix of four common Angular CDK tree mistakes with the symptom each produces for a screen reader user. CDK tree pitfalls and their symptomsSymptomFixaria-level omitteddepth is never announcedbind aria-level to node.level + 1aria-expanded on thetoggle buttonthe row state is not exposedput it on the row, not the buttontrackBy omittedrows remount and focus is losttrackBy a stable node idNo announcementexpansion is silentuse LiveAnnouncer after render
Every one of these passes an automated rule check.

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.

Permalink to "Related"

Back to Expandable Rows & Nested Data