Angular Material, CDK & Accessibility
Most company apps do not design every button from scratch. They use a component library: a set of ready, tested, accessible components such as buttons, form fields, tables, dialogs and date pickers. Angular's official library is Angular Material, built by the Angular team on Google's Material Design 3. Underneath it sits the Component Dev Kit (CDK): unstyled building blocks for behaviour such as drag and drop, overlays, virtual scrolling and accessibility, which you can use with your own design.
In this lesson you will install Material, theme it, build a small leave screen with it, and use the CDK to make custom components accessible to keyboard and screen reader users.
Step by step: add Material to a project
- Run the Angular CLI's
ng addcommand in your project folder:
ng add @angular/materialIt installs @angular/material and @angular/cdk at versions that match your Angular version (22.x for Angular 22), and asks you to pick a starter colour pair: Azure/Blue, Rose/Red, Magenta/Violet or Cyan/Orange.
- Look at the theme it wrote into your global styles. A Material 3 theme is one Sass mixin call that creates CSS variables for colours, typography and density:
@use '@angular/material' as mat;
html {
height: 100%;
@include mat.theme((
color: (
primary: mat.$azure-palette,
tertiary: mat.$blue-palette,
),
typography: Roboto,
density: 0,
));
}
body {
color-scheme: light;
background-color: var(--mat-sys-surface);
color: var(--mat-sys-on-surface);
font: var(--mat-sys-body-medium);
margin: 0;
height: 100%;
}Change color-scheme: light to light dark and the whole app follows the user's system dark mode, with no extra code. Your own components can use the same system variables, such as var(--mat-sys-primary), so they match Material's colours.
- Import only the components you use. Each Material component is standalone, so a component lists exactly what it needs in
imports. This keeps bundles small.
Building a screen with Material
Here is a leave approval panel with a validated form field, a data table and a button that shows a snack bar (a short message at the bottom of the screen):
import { Component, inject, signal } from '@angular/core';
import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
import { MatButton } from '@angular/material/button';
import { MatError, MatFormField, MatHint, MatLabel } from '@angular/material/form-field';
import { MatInput } from '@angular/material/input';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatTableModule } from '@angular/material/table';
interface Leave {
employee: string;
days: number;
}
@Component({
selector: 'app-leave-panel',
imports: [MatTableModule, MatButton, MatFormField, MatLabel, MatError, MatHint, MatInput, ReactiveFormsModule],
template: `
<mat-form-field>
<mat-label>Employee ID</mat-label>
<input matInput [formControl]="empId" />
<mat-hint>Example: EMP-1042</mat-hint>
@if (empId.hasError('pattern')) {
<mat-error>Use the format EMP-1234</mat-error>
}
</mat-form-field>
<table mat-table [dataSource]="leaves()">
<ng-container matColumnDef="employee">
<th mat-header-cell *matHeaderCellDef>Employee</th>
<td mat-cell *matCellDef="let row">{{ row.employee }}</td>
</ng-container>
<ng-container matColumnDef="days">
<th mat-header-cell *matHeaderCellDef>Days</th>
<td mat-cell *matCellDef="let row">{{ row.days }}</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="columns"></tr>
<tr mat-row *matRowDef="let row; columns: columns"></tr>
</table>
<button matButton="filled" (click)="approveAll()">Approve all</button>
`,
})
export class LeavePanel {
private snackBar = inject(MatSnackBar);
protected columns = ['employee', 'days'];
protected empId = new FormControl('', [Validators.pattern(/^EMP-\d{4}$/)]);
protected leaves = signal<Leave[]>([
{ employee: 'Priya', days: 2 },
{ employee: 'Kabir', days: 5 },
]);
protected approveAll() {
this.snackBar.open(`${this.leaves().length} requests approved`, 'Undo', { duration: 4000 });
}
}Typing "emp42" and leaving the field, then clicking the button, gives (checked in a test):
Table: header row + 2 rows -> Priya 2, Kabir 5 Field: "Use the format EMP-1234" (the hint is hidden while the error shows) Snack bar: "2 requests approved" [Undo]
A few things to notice:
matButton="filled"chooses the button style. The appearances aretext,filled,elevated,outlinedandtonal.<mat-form-field>wraps a native<input matInput>, so the label, hint and error are connected to the input for screen readers automatically.- The table's columns are defined once with
matColumnDefand listed in order incolumns. The*matCellDefsyntax is a structural directive, the older template style that the table still uses. - Other common components are
MatDialog(modal windows),MatSelect,MatDatepicker,MatPaginator,MatSortandMatTabs. Browse them at material.angular.dev.
The CDK: behaviour without the look
If your company has its own design system, you may not use Material's look at all. The CDK gives you the hard behaviour parts. Drag and drop is a good example. A manager in Kochi reorders the team's priorities:
import { Component, signal } from '@angular/core';
import { CdkDrag, CdkDragDrop, CdkDropList, moveItemInArray } from '@angular/cdk/drag-drop';
@Component({
selector: 'app-priority-list',
imports: [CdkDropList, CdkDrag],
template: `
<ol cdkDropList (cdkDropListDropped)="drop($event)">
@for (task of tasks(); track task) {
<li cdkDrag>{{ task }}</li>
}
</ol>
`,
})
export class PriorityList {
protected tasks = signal(['Fix payroll bug', 'Review leave policy', 'Plan Diwali party']);
protected drop(event: CdkDragDrop<string[]>) {
this.tasks.update((list) => {
const copy = [...list];
moveItemInArray(copy, event.previousIndex, event.currentIndex);
return copy;
});
}
}Dragging the third item to the top gives:
Plan Diwali party | Fix payroll bug | Review leave policy
moveItemInArray changes the array in place, so the code copies it first and gives the signal a new array, keeping the update immutable. Other CDK packages include @angular/cdk/overlay (floating panels), @angular/cdk/scrolling (virtual scroll for long lists), @angular/cdk/dialog (unstyled dialogs) and @angular/cdk/clipboard.
Accessibility with the CDK
Accessibility (often written a11y) means people who use a keyboard, a screen reader or zoom can use your app. Many clients make it a contract requirement, and it makes apps better for everyone. Material components handle most of it for you; when you build custom widgets, the CDK's @angular/cdk/a11y package helps:
LiveAnnouncertells screen readers about changes that are not focused, such as "2 requests approved". It writes the text into a hiddenaria-liveregion.cdkTrapFocuskeeps keyboard focus inside a custom dialog or drawer, so pressing Tab does not wander into the page behind it.FocusMonitortells you whether an element got focus from the keyboard, mouse, touch or code, so you can show a focus ring only for keyboard users.FocusKeyManagerandActiveDescendantKeyManageradd arrow-key navigation to custom lists and menus.
import { CdkTrapFocus, LiveAnnouncer } from '@angular/cdk/a11y';
@Component({
selector: 'app-approve-drawer',
imports: [CdkTrapFocus],
template: `
<div role="dialog" aria-labelledby="drawer-title" cdkTrapFocus [cdkTrapFocusAutoCapture]="true">
<h2 id="drawer-title">Approve leave</h2>
<button type="button" (click)="approve()">Approve</button>
<button type="button" (click)="closed.emit()">Cancel</button>
</div>
`,
})
export class ApproveDrawer {
private announcer = inject(LiveAnnouncer);
readonly closed = output<void>();
protected async approve() {
await this.announcer.announce('Leave approved for Priya', 'polite');
this.closed.emit();
}
}cdkTrapFocusAutoCapture moves focus into the drawer when it opens and returns it to the previous element when it closes. A newer package, Angular Aria (@angular/aria), offers unstyled, accessible directives for common patterns such as tabs, menus, listboxes, comboboxes, accordions, trees and grids; check its current status on angular.dev before using it in production.
Tools do not replace testing. Use the keyboard only for one full task, turn on a screen reader (NVDA on Windows, TalkBack on Android) for a few minutes, and run Lighthouse's accessibility audit.
Common mistakes
- Importing every Material module "just in case". Import only what each component uses.
- Forgetting
<mat-label>, which leaves inputs without an accessible name. - Mutating the array in a drop handler without giving the signal a new array, so the list does not update.
- Building custom dialogs without focus management, which traps keyboard users outside the dialog.
- Overriding Material's internal CSS classes. Use the theme and system variables instead; internal classes can change between versions.
Key points
ng add @angular/materialinstalls Material and the CDK and sets up a Material 3 theme withmat.theme().- Import standalone Material components individually; use
matButton="filled"style appearances and<mat-form-field>withmatInput. - The CDK provides unstyled behaviour: drag and drop, overlays, virtual scrolling, dialogs and more.
@angular/cdk/a11yaddsLiveAnnouncer,cdkTrapFocus,FocusMonitorand keyboard managers for accessible custom widgets.- Always test with a keyboard and a screen reader, not only with tools.
Exercise
Build a HolidayPicker for a school in Lucknow using Material: a <mat-form-field> with a <mat-select> listing three holidays (Diwali, Holi, Eid), and a button that adds the selected holiday to a list shown below. When a holiday is added, announce "Holi added to the calendar" with LiveAnnouncer. Use matButton="tonal" for the button.
Show solution
import { Component, inject, signal } from '@angular/core';
import { LiveAnnouncer } from '@angular/cdk/a11y';
import { MatButton } from '@angular/material/button';
import { MatFormField, MatLabel } from '@angular/material/form-field';
import { MatOption, MatSelect } from '@angular/material/select';
@Component({
selector: 'app-holiday-picker',
imports: [MatFormField, MatLabel, MatSelect, MatOption, MatButton],
template: `
<mat-form-field>
<mat-label>Holiday</mat-label>
<mat-select [value]="selected()" (valueChange)="selected.set($event)">
@for (h of options; track h) {
<mat-option [value]="h">{{ h }}</mat-option>
}
</mat-select>
</mat-form-field>
<button matButton="tonal" type="button" (click)="add()" [disabled]="!selected()">Add</button>
<ul>
@for (h of added(); track h) {
<li>{{ h }}</li>
}
</ul>
`,
})
export class HolidayPicker {
private announcer = inject(LiveAnnouncer);
protected options = ['Diwali', 'Holi', 'Eid'];
protected selected = signal('');
protected added = signal<string[]>([]);
protected add() {
const h = this.selected();
if (!this.added().includes(h)) {
this.added.update((list) => [...list, h]);
this.announcer.announce(`${h} added to the calendar`, 'polite');
}
}
}The select's label comes from <mat-label>, the list updates through a new array, and screen reader users hear the confirmation even though focus stays on the button.
Quick Quiz
1. What does ng add @angular/material do?
ng add runs Material's setup schematic, including the theme.
2. Which attribute gives a Material button the filled style?
matButton accepts text, filled, elevated, outlined or tonal.
3. What is the CDK?
Material is built on the CDK, and you can use the CDK with your own design.
4. Which CDK tool tells screen readers about a change that is not focused?
LiveAnnouncer writes messages into an aria-live region.
Learn Angular with a mentor
Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
