Back to CourseAdvance5 min read
Angular /

Angular Aria Accessibility Patterns

Build accessible tabs, menus, comboboxes and trees with Angular Aria, the headless directive package that became stable in Angular 22.

Bishal Biswas

Bishal Biswas

Self Learner

Published on: June 24, 2026
Last updated: September 7, 2026
Advance Topic

Behaviour without styling

Accessible widgets are hard. A tab list needs arrow key navigation, roving focus, the right ARIA roles and correct relationships between tabs and panels. Getting all of that right is a lot of work, and getting it subtly wrong is very easy.

@angular/aria provides headless directives implementing the WAI-ARIA patterns. They handle keyboard interaction, focus management and ARIA attributes. They provide no styling at all.

It became stable in Angular 22.

npm install @angular/aria

Tabs

import { Component } from '@angular/core';
import { NgTabs, NgTabList, NgTab, NgTabPanel } from '@angular/aria';

@Component({
  selector: 'app-settings',
  imports: [NgTabs, NgTabList, NgTab, NgTabPanel],
  template: `
    <div ngTabs>
      <div ngTabList>
        <button ngTab value="profile">Profile</button>
        <button ngTab value="security">Security</button>
        <button ngTab value="billing">Billing</button>
      </div>

      <div ngTabPanel value="profile">
        <app-profile-settings />
      </div>

      <div ngTabPanel value="security">
        <app-security-settings />
      </div>

      <div ngTabPanel value="billing">
        <app-billing-settings />
      </div>
    </div>
  `,
})
export class Settings {}

You get arrow key navigation between tabs, home and end keys, correct role="tab" and role="tabpanel", aria-selected, aria-controls and aria-labelledby wiring, and roving tabindex so the tab list is one stop in the tab order.

None of that is in your template, and none of it is styling you have to fight.

Styling headless directives

Because there is no styling, you write CSS against the ARIA attributes the directives manage.

[ngTab] {
  padding: 0.5rem 1rem;
  border: none;
  background: none;
  cursor: pointer;
}

[ngTab][aria-selected="true"] {
  border-bottom: 2px solid var(--brand);
  font-weight: 600;
}

[ngTab][aria-disabled="true"] {
  opacity: 0.5;
  cursor: not-allowed;
}

This is the mental shift. You do not add an active class, you style [aria-selected="true"]. The state is already in the DOM, correctly, for assistive technology, so styling from it keeps the two in step automatically.

The attributes worth knowing:

aria-selected      tabs, options
aria-expanded      accordions, menus, comboboxes
aria-disabled      anything disabled
aria-checked       checkboxes, radios, switches
aria-current       the current page in navigation

The patterns available

Accordion   ngAccordionGroup, ngAccordionTrigger, ngAccordionPanel, ngAccordionContent
Listbox     ngListbox, ngOption
Combobox    ngCombobox, ngComboboxPopup, ngComboboxWidget
Menu        ngMenuBar, ngMenu, ngMenuItem, ngMenuTrigger, ngMenuContent
Tabs        ngTabs, ngTabList, ngTab, ngTabPanel, ngTabContent
Toolbar     ngToolbar, ngToolbarWidget, ngToolbarWidgetGroup
Tree        ngTree, ngTreeItem, ngTreeItemGroup
Grid        ngGrid, ngGridRow, ngGridCell, ngGridCellWidget

Each covers the full keyboard specification for its pattern, which is more than most hand written implementations manage.

Listbox

import { NgListbox, NgOption } from '@angular/aria';

@Component({
  imports: [NgListbox, NgOption],
  template: `
    <ul ngListbox [(value)]="selected">
      @for (country of countries(); track country.code) {
        <li ngOption [value]="country.code">{{ country.name }}</li>
      }
    </ul>
  `,
})
export class CountryPicker {
  protected readonly countries = signal<Country[]>([]);
  protected readonly selected = signal<string | null>(null);
}

Arrow keys move, typing jumps to a matching option, and multi select handles shift and control clicking. All of it is standard behaviour users already expect.

Signal forms integration

The directives expose a value model, so [formField] binds directly.

@Component({
  imports: [NgListbox, NgOption, FormField],
  template: `
    <ul ngListbox [formField]="orderForm.country">
      @for (country of countries(); track country.code) {
        <li ngOption [value]="country.code">{{ country.name }}</li>
      }
    </ul>
  `,
})
export class OrderForm {
  protected readonly model = signal({ country: '' });

  protected readonly orderForm = form(this.model, path => {
    required(path.country, { message: 'Choose a country' });
  });
}

An accessible custom select bound into a validated form, with no ControlValueAccessor to write.

Lazy panel content

The structural content directives render their contents only when needed.

<div ngTabs>
  <div ngTabList>
    <button ngTab value="reports">Reports</button>
  </div>

  <div ngTabPanel value="reports">
    <ng-template ngTabContent>
      <app-expensive-report />
    </ng-template>
  </div>
</div>

ngTabContent inside an ng-template means the report is not created until that tab is opened. The same applies to ngAccordionContent and ngMenuContent.

Combining it with @defer delays the download as well as the creation.

Never replace a native element

The first rule of using this package: do not reimplement something HTML already provides.

<!-- wrong: a native select is better in every way -->
<ul ngListbox>...</ul>

<!-- right -->
<select [formField]="form.country">...</select>

A native <select> gets platform behaviour for free, including the correct mobile picker. Reach for ngListbox only when the design genuinely requires something a <select> cannot do, such as rich option content or multi column layout.

The same goes for <button>, <details>, <dialog> and <input type="checkbox">. Use the platform first.

Testing with harnesses

import { TabsHarness } from '@angular/aria/testing';

it('switches tabs', async () => {
  const fixture = TestBed.createComponent(Settings);
  const loader = TestbedHarnessEnvironment.loader(fixture);

  const tabs = await loader.getHarness(TabsHarness);
  await tabs.selectTab('Security');

  expect(await tabs.getSelectedTabLabel()).toBe('Security');
});

Harnesses test through the same interface a user has, so a test does not break when the internal markup changes.

Aria or Material?

@angular/aria        behaviour only, you write all the CSS
@angular/material    behaviour plus Material Design styling

Use Aria when you have your own design system and want the accessibility handled.

Use Material when Material Design is acceptable and you want components that look finished.

Material is built on the CDK and provides its own accessibility. You do not need both for the same widget.

What it does not do

It does not make your application accessible. It makes these specific widgets accessible. Colour contrast, heading structure, form labels, focus visibility and meaningful alt text are still your responsibility.

It does not replace testing. Use a screen reader, navigate the whole application with only a keyboard, and run an automated checker. Automated tools catch perhaps a third of real problems.

Quick recap

  • @angular/aria provides headless directives implementing WAI-ARIA patterns.
  • It handles keyboard navigation, focus management and ARIA attributes, and no styling.
  • Style from the ARIA attributes, so the visual state and the accessible state stay in step.
  • Patterns include tabs, listbox, combobox, menu, accordion, tree, grid and toolbar.
  • Directives expose a value model, so [formField] binds directly.
  • Content directives inside ng-template render lazily.
  • Never replace a native element that already does the job.
app.ts

import { Component, signal } from '@angular/core';
import { NgTabs, NgTabList, NgTab, NgTabPanel, NgTabContent,
         NgListbox, NgOption } from '@angular/aria';
import { form, required, FormField } from '@angular/forms/signals';
// HEADLESS: keyboard navigation, focus management and ARIA attributes.
// No styling at all, which is the point.
@Component({
  selector: 'app-settings',
  imports: [NgTabs, NgTabList, NgTab, NgTabPanel, NgTabContent,
            NgListbox, NgOption, FormField],
  template: `
    <div ngTabs>
      <div ngTabList>
        <!-- arrow keys, home/end, roving tabindex, role="tab",
             aria-selected, aria-controls: all handled -->
        <button ngTab value="profile">Profile</button>
        <button ngTab value="reports">Reports</button>
      </div>
      <div ngTabPanel value="profile"><app-profile /></div>
      <div ngTabPanel value="reports">
        <!-- inside ng-template, so the report is not created
             until this tab is opened -->
        <ng-template ngTabContent><app-expensive-report /></ng-template>
      </div>
    </div>
    <!-- The directives expose a value model, so [formField] binds directly.
         An accessible custom select in a validated form, with no
         ControlValueAccessor to write. -->
    <ul ngListbox [formField]="orderForm.country">
      @for (c of countries(); track c.code) {
        <li ngOption [value]="c.code">{{ c.name }}</li>
      }
    </ul>
  `,
  // THE MENTAL SHIFT: do not add an `active` class. Style from the ARIA
  // attributes the directives already manage, so the visual state and the
  // accessible state cannot drift apart.
  styles: `
    [ngTab] { padding: 0.5rem 1rem; border: none; background: none; }
    [ngTab][aria-selected="true"] { border-bottom: 2px solid var(--brand); }
    [ngTab][aria-disabled="true"] { opacity: 0.5; cursor: not-allowed; }
    [ngOption][aria-selected="true"] { background: var(--highlight); }
  `,
})
export class Settings {
  protected readonly countries = signal<{ code: string; name: string }[]>([]);
  protected readonly model = signal({ country: '' });
  protected readonly orderForm = form(this.model, path => {
    required(path.country, { message: 'Choose a country' });
  });
}
// The patterns: Accordion, Listbox, Combobox, Menu, Tabs, Toolbar, Tree, Grid.
// Attributes worth styling from:
//   aria-selected  aria-expanded  aria-disabled  aria-checked  aria-current
// FIRST RULE: never replace a native element that already does the job.
//   <select> gets platform behaviour and the right mobile picker for free.
//   Use ngListbox only when the design needs something <select> cannot do.
// Testing goes through harnesses, so it survives markup changes:
//   const tabs = await loader.getHarness(TabsHarness);
//   await tabs.selectTab('Security');
// This makes THESE WIDGETS accessible. Contrast, heading structure, form
// labels and focus visibility are still yours. Test with a real screen
// reader and with the keyboard only.

Was this explanation helpful?

Your feedback helps us improve the content for everyone.