Angular Material
Modern Angular only No equivalent exists in the original 100 Days series. Written fresh for Angular v22, Angular Material v22, Material Design 3.
Lead with this: Angular Material is Google's official Angular component library — 50+ accessible, production-ready UI components built on Material Design 3. Its theming system generates CSS custom properties at build time so a single stylesheet supports both light and dark mode without runtime JavaScript.
What it is
Angular Material (@angular/material) ships pre-built implementations of the
Material Design 3 component set: buttons, inputs, dialogs, navigation drawers,
data tables, date pickers, and more. Each component handles accessibility,
keyboard navigation, ARIA attributes, and focus management — things you'd spend
days implementing correctly from scratch.
The library has three layers:
| Layer | Package | What it provides |
|---|---|---|
| CDK | @angular/cdk | Platform primitives: overlays, drag-drop, virtual scroll, a11y, testing harnesses |
| Material components | @angular/material | 50+ Material Design 3 UI components built on the CDK |
| Material theming | SCSS mixins in @angular/material | Design token system that generates CSS custom properties for your brand colors |
Material components are standalone — import only what you use. The tree-shaker removes everything else.
How it works under the hood
Old approach — M2 static CSS classes
Before Material Design 3 (through Angular Material v14–v17), theming generated static CSS class rules at build time. You defined a Sass palette, called theming mixins, and Angular Material output pre-computed color classes for every component state:
// M2 approach (v14-v17, deprecated — still works via v18 docs)
@use '@angular/material' as mat;
@include mat.core();
$my-primary: mat.define-palette(mat.$indigo-palette);
$my-accent: mat.define-palette(mat.$pink-palette, A200, A100, A400);
$my-theme: mat.define-light-theme((
color: (primary: $my-primary, accent: $my-accent),
));
@include mat.all-component-themes($my-theme);This generated thousands of lines of CSS with hardcoded color values. Switching between light and dark required loading a different CSS bundle. Adding a third brand color meant duplicating the entire theme block.
New approach — M3 CSS custom properties (v19+)
Angular Material v3 (stable from v18, simplified API in v19) uses the browser's
CSS custom properties as design tokens. The mat.theme() mixin generates
CSS variable declarations, not hardcoded class rules:
// M3 approach (v19+ — recommended)
@use '@angular/material' as mat;
html {
color-scheme: light dark;
@include mat.theme((
color: mat.$violet-palette,
typography: Roboto,
density: 0,
));
}What mat.theme() generates — not class rules, but CSS variable declarations:
/* Generated by mat.theme() — CSS custom properties (design tokens) */
html {
--mat-sys-primary: light-dark(#6750a4, #d0bcff);
--mat-sys-on-primary: light-dark(#ffffff, #381e72);
--mat-sys-surface: light-dark(#fffbfe, #1c1b1f);
--mat-sys-on-surface: light-dark(#1c1b1f, #e6e1e5);
/* ... 80+ more design tokens ... */
}The light-dark() CSS function picks between two values based on the active
color-scheme. When the user's OS is in dark mode (and your app sets
color-scheme: light dark), the browser automatically selects the dark value.
No JavaScript required. No bundle swap. One stylesheet, both modes.
This is the architectural shift: M2 baked colors into selector rules at build time; M3 declares intent via variables and lets the browser resolve them at runtime.
How components consume the tokens
Every Material component's styles reference --mat-sys-* variables:
/* Inside MatButton's compiled styles — pseudo-code */
.mat-mdc-button {
color: var(--mdc-text-button-label-text-color, var(--mat-sys-primary));
background: transparent;
}
.mat-mdc-filled-button {
background: var(--mdc-filled-button-container-color, var(--mat-sys-primary));
color: var(--mdc-filled-button-label-text-color, var(--mat-sys-on-primary));
}When you override --mat-sys-primary in your theme, every component that
reads that token updates automatically — no component-specific overrides needed.
Setup
ng add @angular/materialThe schematic:
- Installs
@angular/materialand@angular/cdk - Adds
provideAnimationsAsync()to your providers - Adds the base theme to
styles.scss - Adds
Robotofont and Material Icons toindex.html
// app.config.ts
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideAnimationsAsync(), // lazy-loads animation module (recommended)
// provideAnimations() // eager — use if you need animations on first render
],
};// styles.scss
@use '@angular/material' as mat;
html {
color-scheme: light dark; // enables light-dark() CSS function
@include mat.theme((
color: mat.$violet-palette,
typography: Roboto,
density: 0,
));
}
body {
background: var(--mat-sys-surface);
color: var(--mat-sys-on-surface);
margin: 0;
font-family: Roboto, sans-serif;
}Using Material components
Standalone imports (recommended)
Import only the components your component uses. Angular tree-shakes the rest:
import { Component, inject } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatInputModule } from '@angular/material/input';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatCardModule } from '@angular/material/card';
import { ReactiveFormsModule, FormControl } from '@angular/forms';
@Component({
selector: 'app-login',
standalone: true,
imports: [
ReactiveFormsModule,
MatCardModule,
MatFormFieldModule,
MatInputModule,
MatButtonModule,
MatIconModule,
],
template: `
<mat-card>
<mat-card-header>
<mat-card-title>Sign In</mat-card-title>
</mat-card-header>
<mat-card-content>
<mat-form-field appearance="outline">
<mat-label>Email</mat-label>
<input matInput type="email" [formControl]="email" />
<mat-icon matSuffix>email</mat-icon>
@if (email.hasError('required')) {
<mat-error>Email is required</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Password</mat-label>
<input matInput type="password" [formControl]="password" />
</mat-form-field>
</mat-card-content>
<mat-card-actions>
<button mat-flat-button color="primary" (click)="login()">
Log In
</button>
<button mat-button>Forgot password?</button>
</mat-card-actions>
</mat-card>
`,
})
export class LoginComponent {
email = new FormControl('');
password = new FormControl('');
login() { /* ... */ }
}Common component gallery
<!-- Buttons -->
<button mat-button>Text</button>
<button mat-stroked-button>Outlined</button>
<button mat-flat-button>Filled</button>
<button mat-icon-button><mat-icon>favorite</mat-icon></button>
<button mat-fab><mat-icon>add</mat-icon></button>
<!-- Form fields -->
<mat-form-field appearance="outline">
<mat-label>Name</mat-label>
<input matInput placeholder="Enter name" />
<mat-hint>Shown below the field</mat-hint>
</mat-form-field>
<!-- Select -->
<mat-form-field>
<mat-label>Country</mat-label>
<mat-select>
<mat-option value="us">United States</mat-option>
<mat-option value="fr">France</mat-option>
</mat-select>
</mat-form-field>
<!-- Progress indicators -->
<mat-spinner />
<mat-progress-bar mode="indeterminate" />
<mat-progress-bar mode="determinate" [value]="progress()" />
<!-- Navigation -->
<mat-toolbar color="primary">
<span>App Name</span>
<span class="spacer"></span>
<button mat-icon-button><mat-icon>menu</mat-icon></button>
</mat-toolbar>
<!-- Chips -->
<mat-chip>Angular</mat-chip>
<mat-chip-set>
@for (tag of tags(); track tag) {
<mat-chip (removed)="removeTag(tag)">
{{ tag }}
<mat-icon matChipRemove>cancel</mat-icon>
</mat-chip>
}
</mat-chip-set>
<!-- Snackbar — programmatic -->
<!-- private snackBar = inject(MatSnackBar); -->
<!-- this.snackBar.open('Saved!', 'Dismiss', { duration: 3000 }); -->Theming in depth
Available palettes
// Pre-built Material Design 3 palettes (choose one as your primary)
mat.$red-palette
mat.$pink-palette
mat.$purple-palette
mat.$deep-purple-palette
mat.$indigo-palette
mat.$blue-palette
mat.$light-blue-palette
mat.$cyan-palette
mat.$teal-palette
mat.$green-palette
mat.$light-green-palette
mat.$lime-palette
mat.$yellow-palette
mat.$amber-palette
mat.$orange-palette
mat.$deep-orange-palette
mat.$brown-palette
mat.$grey-palette
mat.$blue-grey-palette
mat.$violet-palette // default for new projects
mat.$rose-paletteCustom color palette from your brand color
Use the Material Design 3 color system to generate a full palette from a single seed color at material.io/color:
// styles.scss — custom palette from brand color
@use '@angular/material' as mat;
// Use hex value of your brand's primary color
$my-palette: mat.define-palette((
0: #000000,
10: #21005d,
20: #381e72,
25: #432b7a,
30: #4f3884,
35: #5c458e,
40: #6750a4,
50: #7f67be,
60: #9a80d9,
70: #b69af5,
80: #d0bcff,
90: #eaddff,
95: #f6edff,
98: #fdf7ff,
99: #fffbfe,
100: #ffffff,
secondary: (/* similar tonal palette */),
neutral: (/* grayscale tones */),
neutral-variant: (/* slightly tinted neutral */),
error: (/* red error palette */),
));
html {
color-scheme: light dark;
@include mat.theme((
color: $my-palette,
typography: 'Inter',
density: 0,
));
}For most projects, using the Material Theme Builder at
material.io/theme-builder to generate your palette and download a SCSS
file is faster than hand-crafting tonal values.
Dark mode strategies
// Strategy 1 — System preference (recommended)
// light-dark() automatically reads prefers-color-scheme
html {
color-scheme: light dark;
@include mat.theme(( color: mat.$violet-palette, ... ));
}
// Strategy 2 — Explicit class toggle (user preference stored in localStorage)
// Toggled by JavaScript: document.documentElement.classList.toggle('dark-theme')
html {
color-scheme: light; // default light
@include mat.theme(( color: mat.$violet-palette, ... ));
}
html.dark-theme {
color-scheme: dark;
}
// Strategy 3 — Explicit theme-type (no light-dark() function)
@include mat.theme((
color: (
theme-type: dark, // light | dark | color-scheme
palette: mat.$violet-palette,
),
));Overriding design tokens
// Override a system token globally
html {
@include mat.theme(( color: mat.$violet-palette ));
// Override a specific token after the theme
--mat-sys-primary: #005cbb; // brand override
--mat-sys-on-primary: #ffffff;
}
// Override a specific component's tokens in a scope
.high-emphasis-area {
@include mat.button-overrides((
filled-container-color: var(--mat-sys-error),
filled-label-text-color: var(--mat-sys-on-error),
));
}
// Override a token for one component instance with :host
// (inside a component's SCSS file)
:host {
--mat-form-field-outline-color: var(--mat-sys-primary);
--mat-form-field-label-text-color: var(--mat-sys-primary);
}Dialogs — programmatic overlay components
Some Material components are opened programmatically rather than used in templates:
import { Component, inject, signal } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { ConfirmDialogComponent } from './confirm-dialog.component';
@Component({ /* ... */ })
export class ProductPageComponent {
private dialog = inject(MatDialog);
private snackBar = inject(MatSnackBar);
async confirmDelete(productId: string): Promise<void> {
const ref = this.dialog.open(ConfirmDialogComponent, {
width: '400px',
data: { title: 'Delete product?', message: 'This cannot be undone.' },
});
const confirmed = await ref.afterClosed().toPromise();
if (confirmed) {
await this.productService.delete(productId);
this.snackBar.open('Product deleted', 'Dismiss', { duration: 3000 });
}
}
}// confirm-dialog.component.ts
import { Component, inject } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef, MatDialogModule } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
@Component({
standalone: true,
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 mat-dialog-close>Cancel</button>
<button mat-flat-button [mat-dialog-close]="true" color="warn">
Delete
</button>
</mat-dialog-actions>
`,
})
export class ConfirmDialogComponent {
data = inject(MAT_DIALOG_DATA);
dialogRef = inject(MatDialogRef);
}Common mistakes
Mistake 1 — Applying old M2 theming API to M3
The old mat.define-light-theme() / mat.all-component-themes() pattern
is the M2 API. If your styles.scss uses it, you're running M2 components
with M2 theming. For v22 new projects, use mat.theme():
// ❌ M2 API — still works but the old design system; not M3
@include mat.core();
$theme: mat.define-light-theme(( color: ( primary: mat.define-palette(mat.$indigo-palette) ) ));
@include mat.all-component-themes($theme);
// ✅ M3 API (v19+) — Material Design 3 with CSS custom properties
html {
color-scheme: light dark;
@include mat.theme(( color: mat.$violet-palette, typography: Roboto ));
}Mistake 2 — Forgetting provideAnimations in providers
Many Material components use Angular animations (dialogs opening, snackbars sliding in, etc.). Without an animations provider, these silently don't animate (dialogs still open but without transition):
// ❌ No animation provider — dialogs open instantly, no transitions
export const appConfig: ApplicationConfig = {
providers: [provideRouter(routes)],
};
// ✅ Add the animations provider (async is recommended)
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideAnimationsAsync(),
],
};Mistake 3 — Using ::ng-deep to override Material internals
With M3, CSS custom properties are the intended override mechanism.
::ng-deep works but creates global leaking styles. Check for a
--mat-* or --mdc-* token first:
// ❌ ng-deep — global, fragile, breaks with internal DOM refactors
:host ::ng-deep .mat-mdc-form-field-outline { border-color: red; }
// ✅ Check material.angular.dev for exposed tokens
:host {
--mat-form-field-outlined-hover-border-color: red;
--mat-form-field-outlined-focus-border-color: red;
}Inspect the component's available tokens at material.angular.dev under
each component's "Theming" tab.
Mistake 4 — Not using NoopAnimationsModule in tests
Component tests that include Material components need animations disabled, otherwise async animation sequences can cause test flakiness:
// ❌ Real animations in tests — inconsistent timing, test failures
TestBed.configureTestingModule({
imports: [MyComponent],
providers: [provideAnimations()],
});
// ✅ Noop animations in tests
import { provideNoopAnimations } from '@angular/platform-browser/animations';
TestBed.configureTestingModule({
imports: [MyComponent],
providers: [provideNoopAnimations()],
});Mistake 5 — Importing MatXxxModule when individual directives suffice
For standalone components, you can import the specific directive/component class instead of the entire module. Modules include extra utilities that may not tree-shake:
// Works but imports the full module (including things you may not need)
imports: [MatButtonModule]
// More precise — imports only the MatButton directive (Angular 15+)
import { MatButton } from '@angular/material/button';
imports: [MatButton]In practice, both tree-shake well with esbuild. Prefer whichever makes your
imports array cleaner and more self-documenting.
How this evolved
-
Angular Material Beta (2016): The library launched alongside Angular 2. Heavily NgModule-based. Material Design 1 styling. No theming system — just pre-built color classes. Required
BrowserAnimationsModulein the root NgModule. -
Angular Material v6–12 (2018–2021): Material Design 2. Sass-based theming with
mat.define-palette(),mat.define-light-theme(),mat.all-component-themes(). The generated static CSS was comprehensive but all hardcoded values — no dynamic theming at runtime. -
Angular Material v14 (2022): MDC migration completed — all components migrated to MDC (Material Design Components for Web) as the underlying implementation. Many
::ng-deepoverrides from M1 broke. CSS class names changed from.mat-buttonto.mat-mdc-button. -
Angular Material v17–18 (2023–2024): Material Design 3 shipped. Initial M3 theming via
mat.define-theme()(experimental). CSS custom properties introduced as the theming primitive.@angular/materialstandalone components fully supported. -
Angular Material v19 (2024): The
mat.theme()mixin — simplified M3 API — stabilized. Replacesmat.define-theme(),mat.define-light-theme(), andmat.all-component-themes(). Dark mode vialight-dark()CSS function enabled by default in new projects. -
Angular Material v22 (now):
mat.theme()is the standard. All components expose CSS custom properties as their styling API. The old M2 API still works but referencesv18.material.angular.devfor docs. New projects: M3 design tokens,mat.theme(),provideAnimationsAsync().
See also
- Component Harnesses — Material ships harnesses for every component; use them in tests instead of raw DOM selectors
- Sass & SCSS —
@use,:host, and CSS custom properties explained; prerequisite for understanding Material theming - View Encapsulation — why
:host { --mat-sys-primary: ... }correctly scopes token overrides to a component - Official Angular Material docs
- Material Theme Builder — generates custom palettes from your brand color
- Material Design 3 spec