Angular CDK ListKeyManager for Custom Grids
Permalink to "Angular CDK ListKeyManager for Custom Grids"Roving focus — one tab stop for a whole composite widget, arrow keys inside — is the foundation of accessible lists, menus, listboxes and grids. Implementing it by hand means handling arrows, Home and End, wrapping, disabled items and typeahead, and keeping tabindex in sync. The Angular CDK’s ListKeyManager family does this for one-dimensional collections: FocusKeyManager moves real DOM focus, ActiveDescendantKeyManager manages a highlighted item for aria-activedescendant patterns. For grids, you combine row-level and cell-level movement.
This guide belongs to Angular data components.
Spec reference
Permalink to "Spec reference"- ARIA roving tabindex and aria-activedescendant — the two techniques for managing focus inside composites.
FocusKeyManager<T extends FocusableOption>— callsfocus()on items; supportswithVerticalOrientation(),withHorizontalOrientation('ltr' | 'rtl'),withWrap(),withTypeAhead(debounce),withHomeAndEnd(),skipPredicate().ActiveDescendantKeyManager<T extends Highlightable>— callssetActiveStyles()/setInactiveStyles(); you setaria-activedescendanton the container.- Relevant criteria: SC 2.1.1 (A), SC 2.4.3 (A), SC 2.4.7 (AA), SC 4.1.2 (A).
When to use — and when not to
Permalink to "When to use — and when not to"Use FocusKeyManager for lists of records, toolbars, tabs and grid rows where each item is focusable. Use ActiveDescendantKeyManager when focus must stay in an input — comboboxes, autocomplete filters.
For full two-dimensional grids, consider whether a library (AG Grid, a CDK-based grid) already implements the model; building your own 2D roving focus is doable but needs careful handling of row and column movement, virtualisation and editing.
The misapplication: using FocusKeyManager but leaving every item with tabindex="0", so Tab still walks through every item.
Annotated code example
Permalink to "Annotated code example"// Items implement FocusableOption
@Directive({ selector: '[appRecordItem]', host: { role: 'option', '[attr.tabindex]': 'tabindex', '[attr.aria-selected]': 'selected' } })
export class RecordItem implements FocusableOption {
tabindex = -1; selected = false; disabled = false;
constructor(public el: ElementRef<HTMLElement>) {}
focus() { this.el.nativeElement.focus(); }
getLabel() { return this.el.nativeElement.textContent?.trim() ?? ''; } // for typeahead
}
@Component({
selector: 'app-record-list',
template: `<div role="listbox" aria-label="Invoices" (keydown)="onKey($event)" (focusin)="sync()"><ng-content/></div>`,
})
export class RecordList implements AfterContentInit {
@ContentChildren(RecordItem) items!: QueryList<RecordItem>;
private km!: FocusKeyManager<RecordItem>;
ngAfterContentInit() {
this.km = new FocusKeyManager(this.items)
.withVerticalOrientation().withWrap(false).withHomeAndEnd().withTypeAhead(400)
.skipPredicate(i => i.disabled);
this.km.change.subscribe(i => this.items.forEach((it, idx) => it.tabindex = idx === i ? 0 : -1)); // roving tabindex
this.items.first && (this.items.first.tabindex = 0);
}
onKey(e: KeyboardEvent) { this.km.onKeydown(e); }
sync() { const i = this.items.toArray().findIndex(it => it.el.nativeElement === document.activeElement); if (i >= 0) this.km.updateActiveItem(i); }
}
Keyboard & AT behaviour
Permalink to "Keyboard & AT behaviour"| Key | Behaviour | Expected announcement |
|---|---|---|
| Tab | Into list at the active item | “Invoices, list box, INV-1042, 3 of 40” |
| Down / Up | Next / previous enabled item | Item name and position |
| Home / End | First / last | Item name |
| Typing “inv-10” | Typeahead to matching item | Item name |
| Tab | Leaves the list | Next control |
Integration context
Permalink to "Integration context"The general technique is described in implementing roving tabindex for custom data grids and the alternative in using aria-activedescendant for grid cell focus. Type-ahead behaviour is covered in type-ahead in trees and grids. Listbox versus grid choices are in listbox versus grid for selectable record lists.
Gotchas
Permalink to "Gotchas"QueryList changes. When items are added or removed (filtering, pagination), the manager keeps working with the updated QueryList, but the active index may point to a different item. Re-anchor by identity after changes.
Virtual scroll. Items outside the viewport do not exist; combine with the CDK virtual scroll viewport carefully, scrolling the target index into view before focusing.
Wrapping. ARIA patterns differ: menus often wrap; grids and listboxes usually do not. Configure per widget.
Common mistakes
Permalink to "Common mistakes"- Leaving all items at tabindex 0.
- Not syncing the manager when users click an item.
- Wrapping in grids where users expect to stop at edges.
- Forgetting skipPredicate for disabled items.
Adoption checklist for teams
Permalink to "Adoption checklist for teams"- Provide base directives for focusable items with tabindex management.
- Choose and document orientation, wrap and typeahead per widget type.
- Sync the manager on click and focusin.
- Re-anchor after data changes by item identity.
- Unit test key handling with keyboard events and focus assertions.
Version and upgrade notes
Permalink to "Version and upgrade notes"ListKeyManager has been stable for many CDK versions; newer versions add options such as withPageUpDown in some managers. Check the API docs for your version, and prefer configuration over subclassing.
Worked example
Permalink to "Worked example"A record list in an Angular CRM had 200 items each with tabindex="0", and arrow keys did nothing. Rebuilding with FocusKeyManager — vertical orientation, Home and End, typeahead and a skip predicate for archived records — reduced the list to one tab stop with full arrow navigation. A follow-up added a second manager for cells within the focused row, producing a simple two-dimensional grid for the CRM’s activity table.
Testing it with a screen reader
Permalink to "Testing it with a screen reader"- Tab into the list; confirm one tab stop and the item name with its position.
- Press Down and Up; confirm each item is announced and focus is visible.
- Press Home and End; confirm the first and last items.
- Type the start of an item name; confirm typeahead moves focus.
- Disable an item in the data; confirm navigation skips it.
- Filter the list; confirm focus re-anchors to a sensible item rather than jumping to the top.
Testing checklist
Permalink to "Testing checklist"Design system notes
Permalink to "Design system notes"Expose list and grid primitives built on ListKeyManager from the design system so teams do not wire managers by hand, and include keyboard tests for each primitive.
FAQ
Permalink to "FAQ"Can ListKeyManager handle two-dimensional grids?
Not on its own; combine a row manager with column tracking, or use a grid library.
Which manager should comboboxes use?
ActiveDescendantKeyManager, so focus stays in the input.
Does the manager set ARIA attributes?
No. You set roles, tabindex and aria-activedescendant; the manager tracks the active item.
How do I handle RTL?
Use withHorizontalOrientation(‘rtl’) for horizontal widgets so Left and Right follow reading direction.
Is FocusKeyManager compatible with signals?
Yes; keep QueryList-based items or adapt to viewChildren signals in newer Angular versions.
How do I test it?
Dispatch keydown events in component tests and assert document.activeElement and tabindex values.
Should Page Up and Page Down work?
For long lists, yes; implement by moving the active index by a page size.
Can I use ListKeyManager with standalone components and signals?
Yes. Use viewChildren or contentChildren signals to get items and create the manager when they are available; update it when the list changes.
Related
Permalink to "Related"- CDK LiveAnnouncer and FocusMonitor — other CDK utilities
- Roving tabindex for custom grids — the technique
- Angular CDK virtual scroll accessibility — virtualized lists