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.
| Question | Description |
|---|---|
| Theme selection | A prebuilt theme (Azure/Blue, Rose/Red, etc.) or a custom one |
| Global typography | Whether to apply the mat-typography class and the Roboto font |
| Animations | Whether 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.
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.
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
| Aspect | Angular Material | MUI (Material UI) |
|---|---|---|
| Maintainer | Angular team (official) | Community / MUI company |
| Version tracking | Major releases move with Angular | Independent of React |
| Styling | SCSS + CSS variables (M3 tokens) | Emotion / styled-system |
| Low-level parts | CDK (same repository) | Separate packages such as Base UI |
| Advanced grid | None (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>.
| Category | Component | Purpose |
|---|---|---|
| Navigation | MatToolbar | Header bar |
MatSidenav | Side navigation / drawer | |
MatMenu | Dropdown menu | |
MatTabs | Tab switching | |
| Forms | MatFormField / MatInput | Input with label and error display |
MatSelect | Select box | |
MatDatepicker | Date selection (requires a date adapter) | |
MatAutocomplete | Input completion | |
| Data display | MatTable / MatPaginator / MatSort | Table, paging, sorting |
| Feedback | MatSnackBar | Transient notifications |
MatDialog | Modal dialogs | |
MatProgressSpinner | Progress indicator | |
| Buttons & icons | MatButton / MatIcon / MatChips | Buttons, icons, tags |
Layout example (Toolbar + Sidenav + Menu)
<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.
@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;
}
}
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.
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();
}
}
<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
| Module | Purpose |
|---|---|
@angular/cdk/drag-drop | Drag and drop, reordering |
@angular/cdk/overlay | Overlays, popups, foundation for tooltips |
@angular/cdk/scrolling | Virtual scrolling (large data display) |
@angular/cdk/a11y | Focus management, screen reader announcements |
@angular/cdk/layout | Breakpoint detection |
@angular/cdk/clipboard | Clipboard operations |
@angular/cdk/portal | Dynamic 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/overlayandListKeyManagerfromcdk/a11y. - Generic parts such as menus, listboxes, and tabs are also available in
@angular/cdk/menuand@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
roleandaria-*attributes (for example,MatSelectusesrole="combobox", andMatDialogusesrole="dialog"andaria-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
| Principle | Action |
|---|---|
| Perceivable | Color contrast of 4.5:1 or higher, alt text, not relying on color alone |
| Operable | All features keyboard-operable, visible focus |
| Understandable | Explicit form labels and error messages |
| Robust | Correct 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
| Library | Characteristics | Suited for |
|---|---|---|
| Angular Material | Official, Material Design, integrated with CDK | Standard business UI, long-term maintenance, a11y focus |
| PrimeNG | Very large number of components, rich themes, enterprise-oriented | Quickly assembling many feature-rich parts |
| NG-ZORRO | Based on Ant Design, rich admin-screen parts | Adopting the Ant Design look |
| DevExtreme | Advanced data grid and charts (commercial license) | Advanced grid needs such as aggregation, grouping, Excel export |
| Taiga UI | Modern design, lightweight, strong TypeScript typing | New projects wanting a modern look and flexibility |
Selection criteria
- 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.
- Design policy: If you have your own design system, CDK or a combination of headless libraries with something like Tailwind fits well.
- Maintainability and license: Check that it is official, how quickly it tracks Angular updates, and commercial license costs.
- Accessibility: If WCAG compliance is mandatory, verify each library's a11y support in practice.
- 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.