1. Installing & Theming with the M3 API
ng add @angular/material installs the package, wires up Roboto and Material
Symbols, and adds a base theme — but the theming API you land in depends on which
Angular Material version the schematic picks. Current Material ships Google's Material
Design 3 (M3) theming system, built on Sass mixins rather than the old prebuilt CSS
files.
@use '@angular/material' as mat;
html {
color-scheme: light dark;
@include mat.theme((
color: mat.$violet-palette,
typography: Roboto,
density: 0,
));
}
mat.theme() generates CSS custom properties for the full M3 token set —
surface colors, on-surface text colors, elevation — and, critically, respects
color-scheme: light dark to automatically switch tokens with the user's OS
preference. That's a different mechanism from this site's own data-theme
toggle (Week 7); when both exist in a real app, you'll want your own dark-mode toggle to
set color-scheme on the root element directly rather than relying on the OS
preference alone.
Older Material tutorials show a long list of @include mat.button-theme(...)-style calls per component. The M3 mat.theme() mixin themes every component that's present in your bundle automatically — you only reach for per-component overrides (mat.button-overrides(...)) when you need to deviate from the generated tokens for one component specifically.
2. Core Components in a Signals-First App
Material's components are standalone by default now, so importing them is exactly like
importing any other standalone dependency (Week 3) — no MatModule grab-bag
required.
import { Component, inject } from '@angular/core';
import { MatDialogRef, MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
@Component({
selector: 'app-confirm-dialog',
imports: [MatDialogModule, MatButtonModule],
template: `
<h2 mat-dialog-title>{{ data.title }}</h2>
<mat-dialog-content>{{ data.message }}</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button (click)="ref.close(false)">Cancel</button>
<button mat-flat-button color="warn" (click)="ref.close(true)">Delete</button>
</mat-dialog-actions>
`,
})
export class ConfirmDialog {
ref = inject(MatDialogRef<ConfirmDialog>);
data = inject<{ title: string; message: string }>(MAT_DIALOG_DATA);
}
import { MatDialog } from '@angular/material/dialog';
private dialog = inject(MatDialog);
confirmDelete(taskTitle: string) {
const ref = this.dialog.open(ConfirmDialog, {
data: { title: 'Delete task?', message: `Delete "${taskTitle}"? This can't be undone.` },
});
ref.afterClosed().subscribe((confirmed) => {
if (confirmed) this.deleteTask();
});
}
MatDialogRef.afterClosed() is still an Observable, not a
signal — a reminder from Week 16 that plenty of Angular's own APIs haven't migrated to
signals, and reaching for toSignal() at the boundary is often cleaner than
subscribing manually everywhere.
3. Material + Reactive Forms, Correctly
mat-form-field and its controls implement ControlValueAccessor
(Week 13), so they drop directly into a typed FormGroup from Week 12 with no
special glue code — the part that trips people up is error display, which Material
doesn't do for you automatically.
<form [formGroup]="form" (ngSubmit)="submit()">
<mat-form-field appearance="outline">
<mat-label>Email</mat-label>
<input matInput formControlName="email" type="email" />
@if (form.controls.email.invalid && form.controls.email.touched) {
<mat-error>
@if (form.controls.email.hasError('required')) { Email is required }
@else if (form.controls.email.hasError('email')) { Enter a valid email }
</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Plan</mat-label>
<mat-select formControlName="plan">
@for (plan of plans; track plan.id) {
<mat-option [value]="plan.id">{{ plan.name }}</mat-option>
}
</mat-select>
</mat-form-field>
<button mat-flat-button type="submit" [disabled]="form.invalid">Sign up</button>
</form>
<mat-error> only renders while the control is genuinely invalid — but
Material won't decide when to show it for you. The &&
.touched check above is doing the same job as the custom
ErrorStateMatcher pattern you'll see in Material's own docs; writing it
inline like this is simpler for most forms and only worth abstracting into a shared
matcher once several forms repeat the exact same show/hide rule.
4. MatTable, Sort & Pagination — Signal-Driven
MatTableDataSource predates signals and expects a plain array or an
Observable — the clean way to drive it from signal state is a small
effect() that pushes new data in whenever your signal changes.
import { Component, signal, effect, viewChild, AfterViewInit } from '@angular/core';
import { MatTableModule, MatTableDataSource } from '@angular/material/table';
import { MatSort, MatSortModule } from '@angular/material/sort';
import { MatPaginator, MatPaginatorModule } from '@angular/material/paginator';
@Component({
selector: 'app-user-table',
imports: [MatTableModule, MatSortModule, MatPaginatorModule],
templateUrl: './user-table.html',
})
export class UserTable implements AfterViewInit {
users = signal<User[]>([]);
displayedColumns = ['name', 'email', 'role', 'actions'];
dataSource = new MatTableDataSource<User>([]);
sort = viewChild.required(MatSort);
paginator = viewChild.required(MatPaginator);
constructor() {
effect(() => {
this.dataSource.data = this.users();
});
}
ngAfterViewInit() {
this.dataSource.sort = this.sort();
this.dataSource.paginator = this.paginator();
}
applyFilter(value: string) {
this.dataSource.filter = value.trim().toLowerCase();
}
}
viewChild.required() (Week 4) replaces @ViewChild here, and the
wiring still has to happen in ngAfterViewInit because
MatSort/MatPaginator are view-queried, DOM-backed instances —
signals don't remove the need for that lifecycle hook, they just remove it from
everywhere else.
5. Material vs. Raw CDK — Which One Do You Reach For?
Week 8 covered the Angular CDK — unstyled, headless building blocks for overlays, drag-and-drop and accessibility. Angular Material is built on top of that same CDK, adding a full set of pre-styled, theme-aware components.
- Reach for Material when you want a complete, accessible, on-brand component fast — buttons, form fields, tables, dialogs, date pickers — and you're fine adopting Material's visual language (or theming it to match your design system, Section 6).
- Reach for raw CDK when your design system has entirely custom visuals and you only need the behavior — positioning, focus trapping, drag reordering — without Material's default look.
- Most real apps use both: Material for the bulk of standard UI, raw CDK for the one or two bespoke pieces that don't fit Material's component set.
This is exactly the "build vs. reach for a library" judgment from Week 8, one level up — Material is itself "a library someone already built," and knowing when to accept its opinions versus drop back down to the CDK is a real day-to-day decision on any team using it.
6. Custom Theming for a Design System
A real product rarely ships Material's default violet palette — you'll generate a custom theme from your brand colors instead.
@use '@angular/material' as mat;
$brand-palette: mat.$violet-palette; // swap for a generated palette from
// Material's theme builder using your
// brand's primary color
html {
color-scheme: light dark;
@include mat.theme((
color: (
theme-type: light,
primary: $brand-palette,
tertiary: mat.$orange-palette,
),
typography: (
brand-family: 'Inter',
plain-family: 'Inter',
),
density: -1,
));
}
:root[data-theme='dark'] {
@include mat.theme((
color: (
theme-type: dark,
primary: $brand-palette,
tertiary: mat.$orange-palette,
),
));
}
Scoping a second mat.theme() call under :root[data-theme='dark']
is exactly how you connect Material's tokens to this site's own manual theme toggle
(Week 7) instead of only the OS-level color-scheme preference — the same
pattern applies to any app with a user-facing light/dark switch.
7. Hands-on Exercise
Build a Material admin table with an edit dialog
A small admin screen that exercises Sections 2 through 4 together.
Requirements:
- A
MatTableof at least 15 seed records (any domain — users, products, tasks), with sorting (MatSort) on at least two columns and pagination (MatPaginator) at 5 rows per page. - A text input above the table wired to
MatTableDataSource.filterfor live filtering as the user types. - An "Edit" button per row that opens a
MatDialogcontaining a Reactive Form (Section 3) pre-filled with that row's data, with at least one required field and one field with a format validator. - Saving the dialog updates the underlying signal (not the
MatTableDataSourcedirectly) — the table should update via theeffect()pattern from Section 4, confirming the data flow is signal-first. - Apply Section 6's custom theming: generate a palette from any brand color of your choosing and confirm it's respected in both light and dark mode.
If the table doesn't visibly update after closing the edit dialog, check that you're calling this.users.update(...) (a new array) rather than mutating an element in place — the same immutability rule from Week 5's signals deep dive applies here, and MatTableDataSource's change detection depends on it just as much as Angular's own template bindings do.
8. Knowledge Check
Four quick questions. Expand each to check your answer.
Q1
What's the actual relationship between Angular Material and the Angular CDK?
What's the actual relationship between Angular Material and the Angular CDK?
The CDK is the unstyled, headless foundation (overlay positioning, focus trapping, drag-and-drop, a11y utilities); Angular Material is a fully-styled, theme-aware component library built on top of it. They're not alternatives to each other — Material depends on the CDK internally.
Q2
Why does <mat-error> need an explicit invalid && touched check in the template?
Why does <mat-error> need an explicit invalid && touched check in the template?
Material renders mat-error content whenever it's present and the control is invalid, but it doesn't decide the UX question of when that should be shown — without the touched check, every required field would show an error message before the user has even interacted with the form.
Q3
Why is an effect() the right bridge between a signal and MatTableDataSource.data?
Why is an effect() the right bridge between a signal and MatTableDataSource.data?
MatTableDataSource predates signals and just holds a plain array property — it has no way to read a signal itself. An effect() re-runs whenever the signal it reads changes, so assigning dataSource.data = this.users() inside one keeps the table in sync without manual subscription management.
Q4
Why wire MatSort and MatPaginator in ngAfterViewInit instead of the constructor?
Why wire MatSort and MatPaginator in ngAfterViewInit instead of the constructor?
They're view-queried directives backed by real DOM elements (viewChild.required()), which aren't resolved yet in the constructor — same lifecycle rule as @ViewChild always had. Signals changed how you read state, not when Angular finishes rendering a view, so DOM-dependent wiring still belongs in ngAfterViewInit.