Making Lists Keyboard-Navigable with Angular CDK
Accessible web applications are easier to use for everyone, especially those relying on assistive technologies. Yet, accessibility is often overlooked, partly because implementing it from scratch can feel time-consuming.
Fortunately, Angular developers have access to a robust toolkit. The @angular/cdk package includes several utilities that streamline accessibility work. Among these tools is a solution for a common interaction pattern: navigating lists with the keyboard.
This guide walks through the process of adding keyboard navigation to lists using Angular CDK. We'll cover the following ground:
- Standard keyboard navigation patterns for lists
- The
ListKeyManagerservice from Angular CDK - A practical example using
FocusKeyManager
Keyboard Navigation Techniques
Components like menus, tables, and trees often share a hidden trait: at their core, they behave like lists. By mastering keyboard navigation for lists, you can apply the same logic to these more complex widgets.
Two main techniques dominate this space. Both allow users to move through items with the arrow keys and both work with screen readers to announce the current selection.
Roving Tabindex
The roving tabindex approach is arguably the most widespread. Its principle is simple: only the currently active item in the list is reachable via the tab key. Once the focus is on the list, the arrow keys take over to shift the focus between items.
This technique is highly compatible with assistive technology because it literally moves the browser focus to the new item. As a result, screen readers automatically announce the element that just received focus.
Here's the implementation logic:
- Give every item in the list
tabindex="-1"so they are skipped during tab navigation. - Assign
tabindex="0"to the currently selected item so it is the entry point for keyboard navigation. - Listen for arrow key events. When an arrow key is pressed, set
tabindex="-1"on the currently active item, settabindex="0"on the next item in the chosen direction, and call itsfocusmethod.
Consider this example:
<ul>
// selected item
<li tabindex="0">Apples</li>
<li tabindex="-1">Bananas</li>
<li tabindex="-1">Cherries</li>
<li tabindex="-1">Pineapple</li>
</ul>
The initial list has the first item focused. When the user presses the down arrow, the state changes like this:
<ul>
<li tabindex="-1">Apples</li>
// selected item
<li tabindex="0">Bananas</li>
<li tabindex="-1">Cherries</li>
<li tabindex="-1">Pineapple</li>
</ul>
The tabindex="0" attribute moves to the next element, and the focus follows. With the focus now on Bananas, a screen reader will announce that item to the user.
aria-activedescendant
The second technique relies on the aria-activedescendant attribute. In this model, the list container itself holds the focus. This attribute points to the id of the active item, and the assistive technology reads the content of that referenced element.
You still need to handle arrow key events to update the selection, but instead of moving the browser focus, you only update this single attribute on the container.
The following example demonstrates this:
<ul aria-activedescendant="cherries" tabindex="0">
<li id="apples">Apples</li>
<li id="bananas">Bananas</li>
// selected item
<li id="cherries">Cherries</li>
<li id="pineapple">Pineapple</li>
</ul>
After the down arrow is pressed, the list's state looks like this:
<ul aria-activedescendant="pineapple" tabindex="0">
<li id="apples">Apples</li>
<li id="bananas">Bananas</li>
<li id="cherries">Cherries</li>
// selected item
<li id="pineapple">Pineapple</li>
</ul>
Notice that the aria-activedescendant on the ul element has been changed to the id of the new item, pineapple. Because the container has focus, the screen reader announces this new value.
Both of these patterns are effective. Implementing them from scratch repeatedly, however, introduces unnecessary complexity. This is precisely where the Angular CDK's ListKeyManager proves its value.
Understanding ListKeyManager
The ListKeyManager class acts as a specialized event handler. It takes a list of items and delegates the appropriate methods to them based on the key events it receives, making keyboard navigation a straightforward process.
Typical integration involves three steps:
- Use a
@ViewChildrenquery to get all the managed option components. - Create a
ListKeyManagerinstance by passing that list of options. - Forward keyboard events from your main component to the
ListKeyManager.
At its core, each list item must conform to an interface:
interface ListKeyManagerOption {
disabled?: boolean;
getLabel?(): string;
}
The two navigation techniques we discussed correspond to two specialized types of ListKeyManager: ActiveDescendantKeyManager and FocusKeyManager.
ActiveDescendantKeyManager
This manager is tailored for the aria-activedescendant pattern. Items managed by it need to implement a specific interface to let the manager update the attribute:
interface Highlightable extends ListKeyManagerOption {
setActiveStyles(): void;
setInactiveStyles(): void;
}
FocusKeyManager
Use this manager when the items themselves should receive browser focus. In this scenario, each item must implement the FocusableOption interface.
interface FocusableOption extends ListKeyManagerOption {
focus(): void;
}
Building a List with FocusKeyManager

Now that we understand the theory, let's put it into practice. While these techniques apply to menus and trees, we'll create a simple list component to illustrate the implementation, specifically using FocusKeyManager.
Our component will be split into two parts: the list container and the individual list item. The public API is defined below.
<my-list>
<my-list-item>Apples</my-list-item>
<my-list-item>Bananas</my-list-item>
<my-list-item>Cherries</my-list-item>
</my-list>
Let's start building the child component.
import { FocusableOption } from '@angular/cdk/a11y';
@Component({
selector: 'my-list-item',
host: {
tabindex: '-1',
role: 'list-item',
},
template: '{{ fruit }}',
})
export class ListItemComponent implements FocusableOption {
@Input() fruit: string;
disabled: boolean;
constructor(private element: ElementRef) {
}
getLabel(): string {
return this.fruit;
}
focus() {
this.element.nativeElement.focus();
}
}
This is our ListItemComponent. It implements FocusableOption, which is the contract required by FocusKeyManager. The manager will invoke the focus method on this component to set the focus to the correct item.
The tabindex="-1" attribute is applied directly to the element. This ensures that the list items are bypassed when the user is tabbing through the page, a key part of the roving tabindex technique.
With the item ready, we can now build the main list component.
@Component({
selector: 'my-list',
host: { role: 'list' },
template: '<ng-content></ng-content>',
})
export class ListComponent implements AfterContentInit {
// 1. Query all child elements
@ContentChildren(ListItem) items: QueryList<ListItem>;
// FocusKeyManager instance
private keyManager: FocusKeyManager<ListItem>;
ngAfterContentInit() {
// 2. Instantiate FocusKeyManager
this.keyManager = new FocusKeyManager(this.items)
// 3. Enabling wrapping
.withWrap();
}
}
- The
ContentChildrendecorator fetches all the projectedListItemComponentinstances that are placed inside the list. - We instantiate the
FocusKeyManager, passing it the collection ofListItemComponentreferences. - Wrapping is enabled here. This means navigating past the last item in the list will cycle the focus back to the first item, and vice versa.
There is one final step. We need to connect the component's keyboard events to the manager.
export class ListComponent implements AfterContentInit {
@HostListener('keydown', ['$event'])
onKeydown(event) {
this.keyManager.onKeydown(event);
}
}
With that, the implementation is complete. The result is a fully functional, keyboard-navigable list.
Keyboard-navigable Fruits list
Wrapping Up
We have successfully implemented accessible keyboard navigation for a list component using ListKeyManager. This approach not only handles the event logic cleanly but also ensures compatibility with assistive technologies out of the box.
Armed with these techniques, you are well-equipped to create accessible menus, trees, and other rich widgets with a fraction of the effort.
Follow along for more updates, and feel free to share any CDK topics you'd like covered next.
References & Further Reading
Explore these guides to deepen your understanding of keyboard navigation:
- Roving tabindex documentation — https://developer.mozilla.org/en-US/docs/Web/Accessibility/Keyboard-navigable_JavaScript_widgets
- Using
aria-activedescendant— https://www.w3.org/WAI/GL/wiki/Using_aria-activedescendant_to_allow_changes_in_focus_within_widgets_to_be_communicated_to_Assistive_Technology
Get the full details on the ListKeyManager API:
- Angular CDK A11y Overview — https://material.angular.io/cdk/a11y/overview#basic-usage
