Angular CDK Virtual Scroll Accessibility

Permalink to "Angular CDK Virtual Scroll Accessibility"

The Angular CDK’s virtual scroll viewport renders a window of a large list and recycles the components outside it. That is exactly the behaviour that breaks the accessibility tree: the DOM contains twenty items, so a screen reader reports twenty items, and the nine thousand nine hundred and eighty the user cannot currently see do not exist as far as assistive technology is concerned. This page covers the bindings that fix it, the announcement policy for movement, and the focus-pinning problem that the CDK does not solve for you.

It is the Angular route into accessible virtualized list patterns, and its counting rules come from aria-colcount and aria-rowcount for partially rendered grids.

Spec reference

Permalink to "Spec reference"

aria-setsize states the total number of items in a set, and aria-posinset the 1-based position of an item within it. Both exist precisely for the case where the DOM does not contain the whole set. In a list context they are the equivalent of aria-rowcount and aria-rowindex in a grid.

Both describe the data, not the DOM. A rendered window of twenty items drawn from a source of ten thousand should carry aria-setsize="10000" on every item and aria-posinset values in the thousands.

-1 is defined as unknown for the set size, which is the correct value while items are still streaming in.

Responsibilities in a CDK virtual scroll list Stack of four layers in an Angular CDK virtual scroll: the viewport, the for-of directive, the item template with its ARIA, and the announcer. Responsibilities in a CDK virtual scroll listcdk-virtual-scroll-viewportrenders a window and manages the spacer height*cdkVirtualForgives you the item and its absolute indexItem templaterole, aria-posinset and aria-setsize bound to that indexLiveAnnouncerposition announcements after a jump, not on every scroll tick
The viewport owns geometry; the item template owns semantics.

When to use — and when not to

Permalink to "When to use — and when not to"

Use virtual scrolling when the list is genuinely large — thousands of items — and the DOM cost of rendering it all would be prohibitive. The threshold in practice is a few hundred rows for a complex row template, higher for simple ones; the reasoning is in DOM size limits and performance tradeoffs.

Do not virtualize a list of fifty items. The accessibility overhead is real — every attribute below is code that can drift — and the performance benefit at that size is nil.

Do not use virtual scrolling as a substitute for pagination when users need addressable positions. A row 4,821 items into a virtual list cannot be linked to; a row on page 97 can.

Annotated code example

Permalink to "Annotated code example"
<!-- SC 1.3.1: the container declares the pattern and the true total -->
<cdk-virtual-scroll-viewport itemSize="48" class="viewport"
                             role="listbox"
                             [attr.aria-setsize]="items.length"
                             aria-label="Transactions">

  <!-- index from cdkVirtualFor is ABSOLUTE, not window-relative -->
  <div *cdkVirtualFor="let item of items; let i = index; trackBy: trackById"
       role="option"
       [attr.aria-posinset]="i + 1"
       [attr.aria-setsize]="items.length"
       [attr.aria-selected]="item.id === selectedId"
       [id]="'item-' + item.id"
       tabindex="-1">
    {{ item.account }} — {{ item.amount | currency }}
  </div>
</cdk-virtual-scroll-viewport>
// SC 4.1.3: announce discrete jumps, never continuous scrolling
constructor(private live: LiveAnnouncer) {}

@ViewChild(CdkVirtualScrollViewport) viewport!: CdkVirtualScrollViewport;

jumpTo(index: number): void {
  this.viewport.scrollToIndex(index, 'smooth');
  // Wait for the window to render before describing the position
  queueMicrotask(() => {
    this.live.announce(
      `Item ${index + 1} of ${this.items.length}: ${this.items[index].account}`,
      'polite');
  });
}

// Stable identity keeps a recycled component from carrying stale attributes
trackById = (_: number, item: Transaction) => item.id;
Binding absolute indexes through the recycler Three-step binding: take the absolute index from cdkVirtualFor, bind the position attributes, and keep the total on the container. Binding absolute indexes through the recyclerTake the indexlet i = index from *cdkVirtualForAbsolute, not window-relativeBind position[attr.aria-posinset]="i + 1" [attr.aria-setsize]="total"One binding per rendered itemKeep the totalaria-rowcount or aria-setsize from the data lengthNever from the rendered count
The index the directive gives you is absolute — use it directly.

Keyboard & AT behaviour

Permalink to "Keyboard & AT behaviour"
Key Expected result Failure indicator
Down / Up arrow Move the active item by one, scrolling if needed Focus moves but the item is off-screen
Page Down / Up Move by one window and announce the new position Silent, so the user loses their place
Home / End Reach the true first and last item of the DATA Reaches only the ends of the rendered window
Tab Leave the list Tab moves through every rendered item

The Home and End row is the one to test first. A great many virtual list implementations bind those keys to the rendered window because that is what the DOM offers, and the bug is invisible unless you scroll to the middle of a long list before pressing End.

Announcing movement in a windowed list Comparison of announcement policies for a virtual scroll list, contrasting discrete jumps with continuous scrolling. Announcing movement in a windowed list✓ AnnouncePage Down and Page Up jumpsHome and End reaching the true endsThe position after a programmatic scrollToIndex✗ Do not announceWheel and trackpad scrollingEvery item entering the windowThe scroll offset in pixels
Scrolling is not a state change; jumping is.

Integration context

Permalink to "Integration context"

The CDK recycles component instances, which means an item component can render a different data item without being destroyed. Angular’s bindings handle that correctly as long as identity is stable — hence trackBy. Where it bites is with imperative DOM work: an id set once in ngOnInit will be wrong after recycling, and an aria-activedescendant reference pointing at it becomes a dangling id, which is silent.

Focus pinning is not provided. If the focused item scrolls out of the window it is recycled and focus is destroyed. The workaround described in the parent guide — keep the focused item rendered regardless of the window — has to be implemented in the application, usually by rendering it absolutely positioned outside the viewport’s recycled range.

Gotchas

Permalink to "Gotchas"

aria-setsize from the rendered array. *cdkVirtualFor iterates the full source, so items.length is correct — but a component that receives only the visible slice as an input will report the window size.

Announcing on scroll events. The viewport emits a scrolled observable; subscribing to it and announcing produces continuous speech.

itemSize mismatched to the real row height. The viewport’s spacer height is then wrong, so the scrollbar and the reported position disagree with reality.

Focus lost on recycle. Covered above, and the single most reported virtualization accessibility bug.

Testing a virtual list without a screen reader

Permalink to "Testing a virtual list without a screen reader"

Three checks in DevTools catch most of what goes wrong here, and none of them needs assistive technology.

First, scroll to the middle of the list and inspect a rendered item. Its aria-posinset should be in the thousands, not in the tens. If the numbers restart at one whenever you scroll, the binding is using the window position.

Second, read aria-setsize on any item and compare it with the data source length. They must match. A value equal to the number of rendered items is the classic failure and it is invisible visually.

Third, focus an item in the middle of the list, then scroll it out of the window with the wheel and check document.activeElement. If it becomes the body, the focused item was recycled and focus was destroyed — the pinning workaround from the parent guide is needed.

FAQ

Permalink to "FAQ"
Does cdkVirtualFor give the absolute index or the window index?

The absolute index. The directive exposes index, count, first, last, even and odd, and index is the position in the full data source rather than in the rendered window. That makes the ARIA bindings straightforward: aria-posinset is index plus one, and aria-setsize is the data source length.

Where should aria-setsize come from?

The data source, always. Reading it from the rendered item count produces “item 3 of 20” in a ten thousand item list, which is the exact failure virtualization introduces. If the total is unknown because items are still streaming, use -1 on the container count attribute until it is known.

Should scrolling be announced?

No. Continuous scrolling is not a state change and announcing it occupies the speech queue permanently. Announce discrete jumps — Page Down, Home, End, and programmatic scrollToIndex calls — with the resulting position, and leave wheel and trackpad scrolling silent.

Permalink to "Related"

Back to Accessible Virtualized List Patterns