EthicsComputerCourses
Join training +91 90532 10052

Angular Material, CDK & Accessibility

Angular · Lesson 26 of 41 Advanced 7 min read Updated

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

  1. Run the Angular CLI's ng add command in your project folder:
Terminal
ng add @angular/material

It 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.

  1. 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:
SCSS
@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.

  1. 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):

TypeScript
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):

Output
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 are text, filled, elevated, outlined and tonal.
  • <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 matColumnDef and listed in order in columns. The *matCellDef syntax is a structural directive, the older template style that the table still uses.
  • Other common components are MatDialog (modal windows), MatSelect, MatDatepicker, MatPaginator, MatSort and MatTabs. 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:

TypeScript
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:

Output
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:

  • LiveAnnouncer tells screen readers about changes that are not focused, such as "2 requests approved". It writes the text into a hidden aria-live region.
  • cdkTrapFocus keeps keyboard focus inside a custom dialog or drawer, so pressing Tab does not wander into the page behind it.
  • FocusMonitor tells you whether an element got focus from the keyboard, mouse, touch or code, so you can show a focus ring only for keyboard users.
  • FocusKeyManager and ActiveDescendantKeyManager add arrow-key navigation to custom lists and menus.
TypeScript
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/material installs Material and the CDK and sets up a Material 3 theme with mat.theme().
  • Import standalone Material components individually; use matButton="filled" style appearances and <mat-form-field> with matInput.
  • The CDK provides unstyled behaviour: drag and drop, overlays, virtual scrolling, dialogs and more.
  • @angular/cdk/a11y adds LiveAnnouncer, cdkTrapFocus, FocusMonitor and 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
TypeScript
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?

2. Which attribute gives a Material button the filled style?

3. What is the CDK?

4. Which CDK tool tells screen readers about a change that is not focused?

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