Skip to main content

Angular Material & CDK

Angular Material is the official UI component library based on Google's Material Design. CDK (Component Dev Kit) provides low-level primitives for building custom components. Together they serve as a powerful foundation for building unified UIs in enterprise development.

1. Getting Started with Angular Material​

Installing with ng add​

Angular Material is maintained by the Angular team, and its major version is aligned with Angular itself. ng add handles everything from package installation to theme setup interactively.

ng add @angular/material

The following questions are asked.

QuestionDescription
Theme selectionA prebuilt theme (Azure/Blue, Rose/Red, etc.) or a custom one
Global typographyWhether to apply the mat-typography class and the Roboto font
AnimationsWhether to enable animations (the prompt varies by version)

ng add also adds @angular/cdk, registers styles in angular.json, and loads Roboto and Material Icons in index.html.

About animations​

Previously BrowserAnimationsModule (or provideAnimations()) was required. Recent Angular Material versions have moved components to CSS-based animations, so it is becoming unnecessary. Check the release notes for the version you use. If an older version needs it, register it in app.config.ts.

app.config.ts
import type { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
import { routes } from './app.routes';

export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
// Only if your Material version still needs animations
provideAnimationsAsync(),
],
};

Importing individual components (standalone)​

All current Angular Material components are standalone. List only the components you need directly in the imports of the consuming component. No NgModule is required.

user-form.component.ts
import { Component } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatIconModule } from '@angular/material/icon';

@Component({
selector: 'app-user-form',
standalone: true,
imports: [MatButtonModule, MatFormFieldModule, MatInputModule, MatIconModule],
template: `
<mat-form-field appearance="outline">
<mat-label>Name</mat-label>
<input matInput />
</mat-form-field>
<button mat-flat-button>
<mat-icon>save</mat-icon>
Save
</button>
`,
})
export class UserFormComponent {}

Only the entry points you use (such as @angular/material/button) are bundled, which works well with tree shaking.

React: comparison with MUI​

AspectAngular MaterialMUI (Material UI)
MaintainerAngular team (official)Community / MUI company
Version trackingMajor releases move with AngularIndependent of React
StylingSCSS + CSS variables (M3 tokens)Emotion / styled-system
Low-level partsCDK (same repository)Separate packages such as Base UI
Advanced gridNone (up to MatTable)Paid MUI X Data Grid

The React side offers many choices and more freedom, but you must pick libraries. In Angular, having an official library is an advantage for long-term maintenance and team standardization.

2. Commonly Used Components​

Components frequently used in business applications, grouped by purpose. All are imported from @angular/material/<name>.

CategoryComponentPurpose
NavigationMatToolbarHeader bar
MatSidenavSide navigation / drawer
MatMenuDropdown menu
MatTabsTab switching
FormsMatFormField / MatInputInput with label and error display
MatSelectSelect box
MatDatepickerDate selection (requires a date adapter)
MatAutocompleteInput completion
Data displayMatTable / MatPaginator / MatSortTable, paging, sorting
FeedbackMatSnackBarTransient notifications
MatDialogModal dialogs
MatProgressSpinnerProgress indicator
Buttons & iconsMatButton / MatIcon / MatChipsButtons, icons, tags

Layout example (Toolbar + Sidenav + Menu)​

app-shell.component.html
<mat-toolbar color="primary">
<button mat-icon-button (click)="drawer.toggle()" aria-label="Toggle menu">
<mat-icon>menu</mat-icon>
</button>
<span>Business System</span>
<span class="spacer"></span>
<button mat-icon-button [matMenuTriggerFor]="userMenu" aria-label="User menu">
<mat-icon>account_circle</mat-icon>
</button>
<mat-menu #userMenu="matMenu">
<button mat-menu-item (click)="logout()">Log out</button>
</mat-menu>
</mat-toolbar>

<mat-sidenav-container>
<mat-sidenav #drawer mode="side" opened>
<mat-nav-list>
<a mat-list-item routerLink="/orders" routerLinkActive="active">Orders</a>
<a mat-list-item routerLink="/customers">Customers</a>
</mat-nav-list>
</mat-sidenav>
<mat-sidenav-content>
<router-outlet />
</mat-sidenav-content>
</mat-sidenav-container>

Form example (with Reactive Forms)​

Combined with a Reactive Forms FormControl, MatFormField can display validation errors declaratively using mat-error.

<mat-form-field appearance="outline">
<mat-label>Email</mat-label>
<input matInput type="email" [formControl]="email" />
@if (email.hasError('required')) {
<mat-error>This field is required</mat-error>
} @else if (email.hasError('email')) {
<mat-error>Invalid format</mat-error>
}
</mat-form-field>

Dialog and SnackBar​

MatDialog and MatSnackBar are services you inject. Data is passed to the component inside a dialog via MAT_DIALOG_DATA.

import { Component, inject } from '@angular/core';
import { MatDialog, MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';

@Component({
selector: 'app-confirm-dialog',
standalone: true,
imports: [MatDialogModule],
template: `
<h2 mat-dialog-title>Confirm deletion</h2>
<mat-dialog-content>Delete {{ data.name }}?</mat-dialog-content>
<mat-dialog-actions>
<button mat-button mat-dialog-close>Cancel</button>
<button mat-flat-button [mat-dialog-close]="true">Delete</button>
</mat-dialog-actions>
`,
})
export class ConfirmDialogComponent {
data = inject<{ name: string }>(MAT_DIALOG_DATA);
}

@Component({ selector: 'app-order-list', standalone: true, template: '' })
export class OrderListComponent {
private dialog = inject(MatDialog);
private snackBar = inject(MatSnackBar);

remove(name: string): void {
this.dialog
.open(ConfirmDialogComponent, { data: { name }, width: '400px' })
.afterClosed()
.subscribe((ok) => {
if (ok) {
this.snackBar.open('Deleted', 'Close', { duration: 3000 });
}
});
}
}

3. Theme Customization​

The M3 (Material 3) theming system​

Since v18, Angular Material supports Material 3 (M3) design tokens. Themes are defined with Sass mixins, and the output is CSS custom properties (system variables). This makes runtime switching and overriding easy.

src/styles.scss
@use '@angular/material' as mat;

html {
color-scheme: light dark;

@include mat.theme(
(
color: mat.$azure-palette,
typography: Roboto,
density: 0,
)
);
}

The mat.theme mixin configures color, typography, and density together. The older approach using mat.define-light-theme and mat.all-component-themes (M2) may still work depending on your version, but the M3 approach is recommended for new development. The API changes quickly, so check the official guide for your version when adopting it.

Switching light/dark mode​

With the color-scheme property you can follow the OS setting through the CSS light-dark() function. To switch with an in-app toggle, control color-scheme with a class.

html {
color-scheme: light;
@include mat.theme((color: mat.$azure-palette, typography: Roboto, density: 0));

&.dark-theme {
color-scheme: dark;
}
}
theme.service.ts
import { DOCUMENT } from '@angular/common';
import { Injectable, inject, signal, effect } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class ThemeService {
private document = inject(DOCUMENT);
readonly dark = signal(false);

constructor() {
effect(() => {
this.document.documentElement.classList.toggle('dark-theme', this.dark());
});
}

toggle(): void {
this.dark.update((v) => !v);
}
}

Per-component overrides​

In M3, each component exposes CSS variables of the form --mat-<component>-<token>, and you can adjust styles by overriding them. This is more resilient to version upgrades than overriding internal classes directly.

// Change the corner radius for specific buttons only
.rounded-button {
@include mat.button-overrides(
(
filled-container-shape: 4px,
)
);
}

// Override a CSS variable directly
.compact-table {
--mat-table-row-item-container-height: 40px;
}

Available token names can change between versions, so check the Theming section of each component's documentation.

4. MatTable (Data Table)​

One of the most used components in enterprise applications. Sorting, pagination, and filtering are easy to achieve with MatTableDataSource.

Basic structure​

Specify a dataSource (the data) and displayedColumns (the visible columns), and define each column with matColumnDef.

order-table.component.ts
import { AfterViewInit, Component, ViewChild } from '@angular/core';
import { MatTableDataSource, MatTableModule } from '@angular/material/table';
import { MatPaginator, MatPaginatorModule } from '@angular/material/paginator';
import { MatSort, MatSortModule } from '@angular/material/sort';
import { MatInputModule } from '@angular/material/input';

interface Order {
id: number;
customer: string;
amount: number;
}

@Component({
selector: 'app-order-table',
standalone: true,
imports: [MatTableModule, MatPaginatorModule, MatSortModule, MatInputModule],
templateUrl: './order-table.component.html',
})
export class OrderTableComponent implements AfterViewInit {
displayedColumns = ['id', 'customer', 'amount'];
dataSource = new MatTableDataSource<Order>([
{ id: 1, customer: 'Acme Trading', amount: 120000 },
{ id: 2, customer: 'Beta Industries', amount: 80000 },
]);

@ViewChild(MatPaginator) paginator!: MatPaginator;
@ViewChild(MatSort) sort!: MatSort;

ngAfterViewInit(): void {
this.dataSource.paginator = this.paginator;
this.dataSource.sort = this.sort;
}

applyFilter(event: Event): void {
const value = (event.target as HTMLInputElement).value;
this.dataSource.filter = value.trim().toLowerCase();
}
}
order-table.component.html
<mat-form-field>
<mat-label>Filter</mat-label>
<input matInput (keyup)="applyFilter($event)" />
</mat-form-field>

<table mat-table [dataSource]="dataSource" matSort>
<ng-container matColumnDef="id">
<th mat-header-cell *matHeaderCellDef mat-sort-header>ID</th>
<td mat-cell *matCellDef="let row">{{ row.id }}</td>
</ng-container>

<ng-container matColumnDef="customer">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Customer</th>
<td mat-cell *matCellDef="let row">{{ row.customer }}</td>
</ng-container>

<ng-container matColumnDef="amount">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Amount</th>
<td mat-cell *matCellDef="let row">{{ row.amount | number }}</td>
</ng-container>

<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>

<tr class="mat-row" *matNoDataRow>
<td class="mat-cell" [attr.colspan]="displayedColumns.length">No matching data</td>
</tr>
</table>

<mat-paginator [pageSizeOptions]="[10, 25, 50]" showFirstLastButtons />

Server-side data sources​

For large data sets, sorting, paging, and filtering are handled on the server. Combine the events of MatSort and MatPaginator and call the API on every change.

import { AfterViewInit, Component, ViewChild, inject, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { MatPaginator } from '@angular/material/paginator';
import { MatSort } from '@angular/material/sort';
import { merge, startWith, switchMap } from 'rxjs';

interface Order { id: number; customer: string; amount: number }
interface Page { items: Order[]; total: number }

@Component({ selector: 'app-remote-table', standalone: true, template: '' })
export class RemoteTableComponent implements AfterViewInit {
private http = inject(HttpClient);
@ViewChild(MatPaginator) paginator!: MatPaginator;
@ViewChild(MatSort) sort!: MatSort;

rows = signal<Order[]>([]);
total = signal(0);

ngAfterViewInit(): void {
// Reset to the first page when the sort changes
this.sort.sortChange.subscribe(() => (this.paginator.pageIndex = 0));

merge(this.sort.sortChange, this.paginator.page)
.pipe(
startWith({}),
switchMap(() =>
this.http.get<Page>('/api/orders', {
params: {
sort: this.sort.active ?? '',
dir: this.sort.direction,
page: this.paginator.pageIndex,
size: this.paginator.pageSize,
},
}),
),
)
.subscribe((res) => {
this.rows.set(res.items);
this.total.set(res.total);
});
}
}

In the template, pass [dataSource]="rows()" and [length]="total()", and display the raw array without MatTableDataSource. switchMap avoids the problem of a stale request's result overwriting a newer one.

Row selection and actions​

Use CDK's SelectionModel for multi-row selection.

import { SelectionModel } from '@angular/cdk/collections';

selection = new SelectionModel<Order>(true, []);

isAllSelected(): boolean {
return this.selection.selected.length === this.dataSource.data.length;
}

toggleAll(): void {
if (this.isAllSelected()) {
this.selection.clear();
} else {
this.selection.select(...this.dataSource.data);
}
}
<ng-container matColumnDef="select">
<th mat-header-cell *matHeaderCellDef>
<mat-checkbox (change)="toggleAll()" [checked]="isAllSelected()" />
</th>
<td mat-cell *matCellDef="let row">
<mat-checkbox (click)="$event.stopPropagation()"
(change)="selection.toggle(row)"
[checked]="selection.isSelected(row)" />
</td>
</ng-container>

For per-row actions, a common pattern is to put a mat-icon-button and a MatMenu in a dedicated column.

5. CDK (Component Dev Kit)​

CDK is a collection of behavior primitives that does not depend on any UI library. Angular Material itself is built on top of CDK. It has no visual design (Material Design) of its own and provides only logic such as drag, overlays, and focus management. In React terms, it is positioned close to "headless UI" libraries such as Radix UI or React Aria.

Commonly used CDK modules​

ModulePurpose
@angular/cdk/drag-dropDrag and drop, reordering
@angular/cdk/overlayOverlays, popups, foundation for tooltips
@angular/cdk/scrollingVirtual scrolling (large data display)
@angular/cdk/a11yFocus management, screen reader announcements
@angular/cdk/layoutBreakpoint detection
@angular/cdk/clipboardClipboard operations
@angular/cdk/portalDynamic content insertion

drag-drop​

List reordering needs only cdkDropList and cdkDrag.

import { CdkDragDrop, CdkDrag, CdkDropList, moveItemInArray } from '@angular/cdk/drag-drop';

@Component({
selector: 'app-task-list',
standalone: true,
imports: [CdkDropList, CdkDrag],
template: `
<div cdkDropList (cdkDropListDropped)="drop($event)">
@for (task of tasks; track task) {
<div cdkDrag class="item">{{ task }}</div>
}
</div>
`,
})
export class TaskListComponent {
tasks = ['Requirements', 'Design', 'Implementation', 'Testing'];

drop(event: CdkDragDrop<string[]>): void {
moveItemInArray(this.tasks, event.previousIndex, event.currentIndex);
}
}

For moving items between lists, use transferArrayItem and cdkDropListConnectedTo to build Kanban-style UIs.

overlay​

The Overlay service provides a mechanism for displaying elements at the front of the screen. It is the foundation for dropdowns, popovers, and custom dialogs. positionStrategy specifies the position relative to an origin element, and scrollStrategy specifies behavior on scroll.

import { Overlay, OverlayModule } from '@angular/cdk/overlay';
import { ComponentPortal } from '@angular/cdk/portal';

private overlay = inject(Overlay);

open(origin: HTMLElement): void {
const positionStrategy = this.overlay
.position()
.flexibleConnectedTo(origin)
.withPositions([
{ originX: 'start', originY: 'bottom', overlayX: 'start', overlayY: 'top' },
]);

const ref = this.overlay.create({
positionStrategy,
scrollStrategy: this.overlay.scrollStrategies.reposition(),
hasBackdrop: true,
backdropClass: 'cdk-overlay-transparent-backdrop',
});

ref.attach(new ComponentPortal(PopupComponent));
ref.backdropClick().subscribe(() => ref.dispose());
}

virtual-scrolling​

When displaying lists of thousands of items or more, generating only the elements in the visible range in the DOM greatly reduces rendering cost.

<cdk-virtual-scroll-viewport itemSize="48" class="viewport">
<div *cdkVirtualFor="let item of items" class="row">{{ item.name }}</div>
</cdk-virtual-scroll-viewport>
.viewport {
height: 480px; // A height is required
}

This assumes each row has a fixed height (itemSize). If variable heights are needed, use the autosize strategy or consider another library. It corresponds to react-window / react-virtualized in React.

layout (BreakpointObserver)​

You can control responsive display in code instead of in the template.

import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout';
import { toSignal } from '@angular/core/rxjs-interop';
import { map } from 'rxjs';

export class ShellComponent {
private bp = inject(BreakpointObserver);
isHandset = toSignal(
this.bp.observe(Breakpoints.Handset).pipe(map((r) => r.matches)),
{ initialValue: false },
);
}
<mat-sidenav [mode]="isHandset() ? 'over' : 'side'" [opened]="!isHandset()" />

clipboard and portal​

<button [cdkCopyToClipboard]="apiKey" (cdkCopyToClipboardCopied)="onCopied($event)">
Copy
</button>

portal separates "the content (Portal)" from "the place that displays it (PortalOutlet)". Overlay and Dialog use it internally. With ComponentPortal or TemplatePortal you can dynamically render a component or template at another location in the app. The concept is close to React's createPortal.

Building a custom design system with CDK only​

If your company's design system differs from Material Design, you can skip Angular Material and adopt only CDK.

  • Implement the look with your own SCSS / CSS variables, and leave behavior (focus trapping, keyboard handling, positioning, virtualization) to CDK.
  • For example, build your own dropdown from cdk/overlay and ListKeyManager from cdk/a11y.
  • Generic parts such as menus, listboxes, and tabs are also available in @angular/cdk/menu and @angular/cdk/listbox.

The benefit is design freedom without the burden of implementing accessibility from scratch.

6. Accessibility (a11y)​

In enterprise settings, compliance with WCAG 2.1 (AA level in many organizations) is often required. Angular Material and CDK support that implementation.

Angular Material's standard support​

  • Each component automatically gets appropriate role and aria-* attributes (for example, MatSelect uses role="combobox", and MatDialog uses role="dialog" and aria-modal).
  • Keyboard operation (arrow keys, Enter, Esc, Tab) is supported out of the box.
  • When a dialog closes, focus automatically returns to the element that was focused before it opened.

However, only component-level support is automatic. Adding aria-label to icon-only buttons, mat-label on forms, and proper color contrast remain the developer's responsibility.

FocusTrap and FocusMonitor​

cdkTrapFocus keeps focus from leaving a modal. FocusMonitor can tell the origin of focus (keyboard, mouse, touch, program).

<div cdkTrapFocus [cdkTrapFocusAutoCapture]="true" class="modal">
<input />
<button>Close</button>
</div>
import { FocusMonitor } from '@angular/cdk/a11y';

private focusMonitor = inject(FocusMonitor);

ngAfterViewInit(): void {
this.focusMonitor.monitor(this.el.nativeElement, true).subscribe((origin) => {
// origin: 'keyboard' | 'mouse' | 'touch' | 'program' | null
console.log('focus origin:', origin);
});
}

You can, for example, show a focus ring only when origin is keyboard.

LiveAnnouncer (screen reader support)​

Asynchronous changes on screen (save completed, search result count updated) do not reach screen reader users if they are conveyed only visually. LiveAnnouncer announces them using ARIA live regions.

import { LiveAnnouncer } from '@angular/cdk/a11y';

private announcer = inject(LiveAnnouncer);

onSearchDone(count: number): void {
this.announcer.announce(`${count} results found`, 'polite');
}

Use assertive for urgent notifications and polite otherwise.

Keyboard navigation​

For custom list-style components, the ListKeyManager family (ActiveDescendantKeyManager, FocusKeyManager) implements arrow key navigation.

import { FocusKeyManager } from '@angular/cdk/a11y';

@ViewChildren(MyItemComponent) items!: QueryList<MyItemComponent>;
private keyManager!: FocusKeyManager<MyItemComponent>;

ngAfterViewInit(): void {
this.keyManager = new FocusKeyManager(this.items).withWrap().withHomeAndEnd();
}

onKeydown(event: KeyboardEvent): void {
this.keyManager.onKeydown(event);
}

Practical points for WCAG 2.1​

PrincipleAction
PerceivableColor contrast of 4.5:1 or higher, alt text, not relying on color alone
OperableAll features keyboard-operable, visible focus
UnderstandableExplicit form labels and error messages
RobustCorrect semantics, appropriate use of ARIA

It is recommended to put automated tests with axe-core (such as @axe-core/playwright) in CI and combine them with manual screen reader checks (NVDA, VoiceOver).

7. Comparison with Other UI Libraries​

LibraryCharacteristicsSuited for
Angular MaterialOfficial, Material Design, integrated with CDKStandard business UI, long-term maintenance, a11y focus
PrimeNGVery large number of components, rich themes, enterprise-orientedQuickly assembling many feature-rich parts
NG-ZORROBased on Ant Design, rich admin-screen partsAdopting the Ant Design look
DevExtremeAdvanced data grid and charts (commercial license)Advanced grid needs such as aggregation, grouping, Excel export
Taiga UIModern design, lightweight, strong TypeScript typingNew projects wanting a modern look and flexibility

Selection criteria​

  1. Nature of requirements: For standard CRUD screens, Angular Material is often enough. If an advanced grid (pivot, virtualization, row grouping) is mandatory, consider DevExtreme or AG Grid.
  2. Design policy: If you have your own design system, CDK or a combination of headless libraries with something like Tailwind fits well.
  3. Maintainability and license: Check that it is official, how quickly it tracks Angular updates, and commercial license costs.
  4. Accessibility: If WCAG compliance is mandatory, verify each library's a11y support in practice.
  5. Risk of mixing: Combining several libraries leads to style conflicts and larger bundles, so basically stick to one.

A realistic and common pattern is to use Angular Material as the base and supplement only the missing grid with a dedicated library.