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> — calls focus() on items; supports withVerticalOrientation(), withHorizontalOrientation('ltr' | 'rtl'), withWrap(), withTypeAhead(debounce), withHomeAndEnd(), skipPredicate().
  • ActiveDescendantKeyManager<T extends Highlightable> — calls setActiveStyles() / setInactiveStyles(); you set aria-activedescendant on the container.
  • Relevant criteria: SC 2.1.1 (A), SC 2.4.3 (A), SC 2.4.7 (AA), SC 4.1.2 (A).
FocusKeyManager versus ActiveDescendantKeyManager Comparison: FocusKeyManager moves real focus to items (roving tabindex); ActiveDescendantKeyManager keeps focus on the container and highlights items via aria-activedescendant. FocusKeyManager versus ActiveDescendantKeyManager✗ FocusKeyManagerReal DOM focus on each itemRoving tabindex (0 / -1)Items must be focusableWorks with any interactive item contentScroll into view by browser focus✓ ActiveDescendantKeyManagerFocus stays on containeraria-activedescendant on containerItems need IDs and highlight stylesGood for comboboxes and listboxesScroll into view yourself

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.

Building a 2D grid on ListKeyManager Flow: a FocusKeyManager over rows handles Up, Down, Home and End; a per-row index tracks the column; Left and Right move within the row; focus moves to the cell at row and column; tabindex follows focus. Building a 2D grid on ListKeyManagerRow managerFocusKeyManager over rowsUp, Down, Ctrl+Home, Ctrl+EndColumn indexstored per gridLeft, Right, Home, EndFocus cellrow.cells[col].focus()real focusTabindex0 on focused cell, -1 elsewhereone tab stopSkip predicatedisabled or hidden rowsconsistent movement

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
ListKeyManager options and their effect Matrix of options — vertical orientation, horizontal with rtl, wrap, home and end, typeahead, skip predicate — against the behaviour they enable. ListKeyManager options and their effectBehaviour enabledwithVerticalOrientationUp and Down movewithHorizontalOrientation('rtl')Left and Right respect directionwithWrapEnd wraps to start (choose per pattern)withHomeAndEndHome and End jumpwithTypeAheadJump by typingskipPredicateSkip disabled or hidden items
Each option corresponds to part of the ARIA keyboard contract.

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"
  1. Provide base directives for focusable items with tabindex management.
  2. Choose and document orientation, wrap and typeahead per widget type.
  3. Sync the manager on click and focusin.
  4. Re-anchor after data changes by item identity.
  5. 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"
  1. Tab into the list; confirm one tab stop and the item name with its position.
  2. Press Down and Up; confirm each item is announced and focus is visible.
  3. Press Home and End; confirm the first and last items.
  4. Type the start of an item name; confirm typeahead moves focus.
  5. Disable an item in the data; confirm navigation skips it.
  6. 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.

Permalink to "Related"

← Back to Angular Data Components