Angular Aria (@angular/aria, introduced in v21) is a collection of headless directives: complete behaviour, accessibility (ARIA roles, keyboard, focus) and zero styles. It’s the ideal foundation for your own design system: you write the CSS, Angular handles the hard part — exactly what almost everyone gets wrong when they write tabs or dropdowns by hand.
npm i @angular/aria
# Patterns: Accordion, Combobox (autocomplete/select), Grid, Listbox, Menu, Tabs, Toolbar, TreeTabs
import { Tab, TabContent, TabList, TabPanel, Tabs } from '@angular/aria/tabs';
@Component({
imports: [Tabs, TabList, Tab, TabPanel, TabContent],
template: `
<div ngTabs>
<ul ngTabList [(selectedTab)]="selected">
<li ngTab value="profil">Profile</li>
<li ngTab value="securitate">Security</li>
</ul>
<div ngTabPanel value="profil"><ng-template ngTabContent>…</ng-template></div>
<div ngTabPanel value="securitate"><ng-template ngTabContent>…</ng-template></div>
</div>
`,
styles: `
/* You style by the ARIA attributes that the directives set */
[ngTab][aria-selected='true'] { border-bottom: 2px solid var(--accent); }
`,
})
export class SettingsTabs {
readonly selected = signal<string | undefined>('profil');
}- Profile
- Security
- Notifications
selectedTab (model): profil
Accordion
<div ngAccordionGroup [multiExpandable]="false">
<button ngAccordionTrigger [panel]="p1">Question 1</button>
<div ngAccordionPanel #p1="ngAccordionPanel">
<ng-template ngAccordionContent>Answer rendered lazily, on opening.</ng-template>
</div>
</div>Listbox
<!-- selectionMode="explicit": click / Space toggles the selection (otherwise the selection follows focus) -->
<ul ngListbox [multi]="true" selectionMode="explicit" [(value)]="toppings" aria-label="Toppings">
@for (t of options; track t) {
<li ngOption [value]="t" [label]="t">{{ t }}</li>
}
</ul>- ✓Mozzarella
- ✓Mushrooms
- ✓Olives
- ✓Peppers
- ✓Pineapple
value (model): mozzarella · keyboard: ↑ ↓, Space, Ctrl+A, type a letter
Menu: a menu with a submenu
A button with ngMenuTrigger opens an ngMenu; each option is an ngMenuItem, and an item with [submenu] opens another menu. You get full keyboard support (arrows, Enter, Escape, typeahead) and the menu / menuitem roles. The choice arrives through the itemSelected output, and with ng-template ngMenuContent the content is only rendered when the menu opens.
<!-- the menu opens from the button; “Share” opens a submenu -->
<button ngMenuTrigger [menu]="actions">Actions</button>
<div ngMenu #actions="ngMenu" (itemSelected)="run($event)">
<ng-template ngMenuContent>
<div ngMenuItem value="edit">Edit</div>
<div ngMenuItem value="duplicate">Duplicate</div>
<div ngMenuItem value="share" [submenu]="share">Share</div>
</ng-template>
</div>
<div ngMenu #share="ngMenu" (itemSelected)="run($event)">
<div ngMenuItem value="link">Copy link</div>
<div ngMenuItem value="email">Email</div>
</div>Toolbar: one Tab stop for a group of buttons
In a toolbar, Tab should enter the group once and the arrow keys should move focus between buttons (the so-called roving tabindex). ngToolbar does exactly that: you mark each button with ngToolbarWidget, and the directive manages tabindex, orientation and disabled buttons.
<!-- Tab enters the bar once; ← → move focus between buttons -->
<div ngToolbar aria-label="Formatting">
<button ngToolbarWidget (click)="bold()">B</button>
<button ngToolbarWidget (click)="italic()">I</button>
<button ngToolbarWidget [disabled]="!canUndo()" (click)="undo()">↶</button>
</div>How to style it: ARIA attributes are the API
The directives do not add classes; they set the correct ARIA attributes: aria-selected, aria-expanded, aria-disabled. Style against those, not against classes you set by hand. That way the look cannot fall behind the state: if the screen reader says “selected”, the user also sees “selected”.
/* State comes from the attributes set by the directives, not from your own classes */
[ngTab][aria-selected='true'] {
border-bottom: 2px solid var(--accent);
}
[ngAccordionTrigger][aria-expanded='true'] .chevron {
rotate: 180deg;
}
[ngOption][aria-selected='true']::before {
content: '✓ ';
}
[aria-disabled='true'] {
opacity: 0.5;
cursor: not-allowed;
}Testing with harnesses
Every pattern has a harness in @angular/aria/<pattern>/testing. The test speaks in the user’s terms (“select the Security tab”), not in CSS selectors, so it does not break when you change the markup.
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
import { TabsHarness } from '@angular/aria/tabs/testing';
it('selects the tab by its title', async () => {
const fixture = TestBed.createComponent(SettingsPage);
const loader = TestbedHarnessEnvironment.loader(fixture);
const tabs = await loader.getHarness(TabsHarness);
await tabs.selectTab({ title: 'Security' });
const selected = await tabs.getSelectedTab();
expect(await selected?.getTitle()).toBe('Security');
});Aria, CDK or Material?
| Angular Aria | Angular CDK | Angular Material | |
|---|---|---|---|
| What it is | accessible headless UI patterns | behaviour primitives (overlay, drag, a11y, scrolling) | complete components, with Material styling |
| Styling | none | none | Material Design (customisable with tokens) |
| When | your own design system | low-level building blocks | you want a ready UI, fast |
A control built on Aria connects to forms as shown in Custom form controls; styling through ARIA attributes follows the rules in Component styles & encapsulation.
When YES, when NO
- You build your own components (a design system) and want correct accessibility from the start.
- You have hand-written tabs / menus / comboboxes with no keyboard support.
- You need full control over the markup and styling.
- You already use Material / PrimeNG / another complete library — there the behaviour is included.
- You only need an overlay or drag & drop → CDK.
Common pitfalls
- A list or tab group without a name: if there is no visible heading, add
aria-label. Otherwise the screen reader only announces “list” or “tab list”. - State classes set by hand next to the Aria state (
[class.active]): the first slip and they no longer match. Style againstaria-*. - Your own
(click)on items: you duplicate the behaviour and lose the keyboard. Listen to the directive’s output (itemSelected) or bindvalue. - A native
<select>is sometimes enough: it is accessible for free and works perfectly on mobile. Aria pays off when you need a look or a behaviour the native element cannot give you.
Cheat sheet
@angular/aria/tabs ngTabs ngTabList ngTab ngTabPanel
@angular/aria/accordion ngAccordionGroup ngAccordionTrigger ngAccordionPanel
@angular/aria/listbox ngListbox ngOption
@angular/aria/menu ngMenuTrigger ngMenu ngMenuItem ngMenuBar ngMenuContent
@angular/aria/toolbar ngToolbar ngToolbarWidget ngToolbarWidgetGroup
@angular/aria/combobox ngCombobox ngComboboxWidget
@angular/aria/tree ngTree ngTreeItem
@angular/aria/grid ngGrid ngGridRow ngGridCell ngGridCellWidget
test harnesses: @angular/aria/<pattern>/testing (e.g. TabsHarness, MenuHarness)