Angular Material and CDK: Setup, Material 3 Theming and Dark Mode [2026]
Most Angular apps need the same set of hard components: dialogs that trap focus, menus that position themselves near the edge of the screen, date pickers, data tables, sortable lists. Writing them yourself is a long project, and getting the keyboard and screen-reader behaviour right is longer. The Angular team maintains three layers of help for this: Angular Material for finished, styled components, the Component Dev Kit (CDK) for unstyled behaviour, and Angular Aria for headless, accessible patterns. Knowing which layer to reach for saves weeks.
This is lesson 14.5 of the Angular Tutorial. It follows lesson 14.4 on custom form controls, and it builds on lesson 2.11, since theming is really a question of how global styles and encapsulated component styles meet. You'll learn how to install Material, set up a Material 3 theme with mat.theme(), use and override design tokens, support dark mode, use the CDK's most useful primitives, and decide between Material, the CDK and Angular Aria.
Three layers #
| Layer | Package | What you get | Styling |
|---|---|---|---|
| Angular Material | @angular/material |
Complete components: buttons, form fields, dialogs, tables, date pickers | Material Design 3, customised through tokens |
| CDK | @angular/cdk |
Behaviour primitives: overlays, focus management, drag-and-drop, virtual scrolling, breakpoints | None; you write all the CSS |
| Angular Aria | @angular/aria |
Headless directives for WAI-ARIA patterns: tabs, menus, listbox, combobox, tree, grid | None; you write all the CSS |
Material is built on the CDK, so installing Material gives you both.
Installing Angular Material #
ng add @angular/material
The schematic installs Material and the CDK, asks whether you want a prebuilt theme or a custom one, and whether to apply global typography styles. It also adds the Roboto font and the Material Symbols icon font to index.html and a few global styles (no body margin, full-height html and body). Components are standalone, so you import exactly what a component uses:
import { MatButton } from '@angular/material/button';
import { MatSlideToggle } from '@angular/material/slide-toggle';
@Component({
selector: 'app-settings',
imports: [MatButton, MatSlideToggle],
template: `
<mat-slide-toggle>Email notifications</mat-slide-toggle>
<button matButton="filled">Save</button>
`,
})
export class Settings {}
If you picked a prebuilt theme, it's a CSS file in the styles array of angular.json. Four prebuilt themes use Material 3 (azure-blue, rose-red, cyan-orange, magenta-violet); the other four are older Material 2 themes kept for backwards compatibility. For anything beyond a prototype, use a custom theme.
A Material 3 theme with mat.theme() #
A custom theme is a Sass file, usually src/styles.scss, that includes one mixin:
@use '@angular/material' as mat;
html {
color-scheme: light dark;
@include mat.theme((
color: mat.$violet-palette,
typography: Roboto,
density: 0,
));
}
body {
background: var(--mat-sys-surface);
color: var(--mat-sys-on-surface);
}
mat.theme() doesn't output component styles. It outputs CSS custom properties, system-level design tokens such as --mat-sys-primary, --mat-sys-surface and --mat-sys-body-large, and every Material component reads from them. Its map has three keys:
| Key | Accepts | Notes |
|---|---|---|
color |
A palette, or a map: primary, tertiary, theme-type |
Twelve prebuilt palettes ($azure-palette, $rose-palette…); theme-type is color-scheme (default), light or dark |
typography |
A font family, or a map: plain-family, brand-family, bold-weight, medium-weight, regular-weight |
The brand family is used for headings and titles |
density |
0 to -5 |
Each step reduces component sizes by 4px; values below 0 can hurt accessibility |
Only the keys you pass produce variables, so a theme with no typography key emits no typography tokens. For a brand colour that isn't one of the twelve palettes, generate palettes from it with the Material schematic:
ng generate @angular/material:theme-color
Using tokens in your own components #
Because the theme is just CSS variables, your components can use it too, which keeps custom UI consistent with Material components and makes it follow dark mode automatically:
// order-summary.scss: component styles, still encapsulated
.summary {
background: var(--mat-sys-primary-container);
color: var(--mat-sys-on-primary-container);
border: 1px solid var(--mat-sys-outline-variant);
font: var(--mat-sys-body-large);
}
This works with emulated view encapsulation (lesson 2.11): custom properties inherit through the DOM, so a variable defined on html reaches every component, while the selectors in your component stylesheet stay scoped.
Overriding tokens #
When the theme is close but not right, override tokens through the theming API instead of writing CSS against Material's internal classes. There are two levels:
@use '@angular/material' as mat;
html {
@include mat.theme((color: mat.$violet-palette, typography: Roboto, density: 0));
// system level: redefine a system token for the whole app (or a subtree)
@include mat.theme-overrides((
primary-container: #dbe4ff,
));
// component level: change one component's tokens
@include mat.card-overrides((
elevated-container-shape: 16px,
title-text-size: 1.25rem,
));
}
Each component's documentation lists its overridable tokens on its Styling tab, and the overrides mixins check token names at compile time, so a typo fails the build instead of silently doing nothing. You can also scope a different theme to part of the page by calling mat.theme() again on a selector, for example a red-toned palette on a .danger-zone container.
Avoid ::ng-deep .mdc-... or .mat-mdc-... overrides. Material treats its DOM structure and class names as private, and they change between versions; token overrides are the supported, upgrade-safe path.
Dark mode #
Material 3 colour tokens are defined with the CSS light-dark() function, which picks a value based on the element's color-scheme. That gives you three simple strategies:
// 1. follow the operating system
html { color-scheme: light dark; }
// 2. always light, dark when a class is added
html { color-scheme: light; }
body.dark-mode { color-scheme: dark; }
// 3. support older browsers: emit only one set of values
// (theme-type: light or dark in the color map)
For a user-controlled toggle, store the choice and set the class or color-scheme on document.body. If you don't set color-scheme at all, light colours are always used. Material doesn't apply prefers-color-scheme or prefers-contrast on its own; you decide the policy.
The CDK: behaviour without styles #
The CDK is useful even if you never use a Material component. Five areas cover most needs:
| Area | Import from | Key APIs | Use for |
|---|---|---|---|
| Overlay | @angular/cdk/overlay |
CdkConnectedOverlay, CdkOverlayOrigin, Overlay |
Popovers, dropdowns, custom dialogs positioned relative to a trigger |
| Accessibility | @angular/cdk/a11y |
CdkTrapFocus, LiveAnnouncer, FocusMonitor |
Focus trapping in dialogs, screen-reader announcements, keyboard vs mouse focus |
| Drag and drop | @angular/cdk/drag-drop |
CdkDropList, CdkDrag, moveItemInArray |
Sortable lists, kanban boards |
| Scrolling | @angular/cdk/scrolling |
CdkVirtualScrollViewport, CdkVirtualForOf |
Rendering only visible rows of long lists |
| Layout | @angular/cdk/layout |
BreakpointObserver, Breakpoints |
Reacting to screen size in code |
A sortable list with live announcements and a responsive layout, in one component:
import { Component, inject, signal } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { map } from 'rxjs';
import { CdkDrag, CdkDragDrop, CdkDropList, moveItemInArray } from '@angular/cdk/drag-drop';
import { LiveAnnouncer } from '@angular/cdk/a11y';
import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout';
@Component({
selector: 'app-priorities',
imports: [CdkDropList, CdkDrag],
template: `
<ol cdkDropList (cdkDropListDropped)="drop($event)" [class.compact]="isHandset()">
@for (task of tasks(); track task) {
<li cdkDrag>{{ task }}</li>
}
</ol>
`,
})
export class Priorities {
private announcer = inject(LiveAnnouncer);
tasks = signal(['Fix login bug', 'Write release notes', 'Review PR']);
isHandset = toSignal(
inject(BreakpointObserver).observe(Breakpoints.Handset).pipe(map(r => r.matches)),
{ initialValue: false },
);
drop(event: CdkDragDrop<string[]>): void {
const list = [...this.tasks()];
moveItemInArray(list, event.previousIndex, event.currentIndex);
this.tasks.set(list);
this.announcer.announce(`${list[event.currentIndex]} moved to position ${event.currentIndex + 1}`);
}
}
Note the copy before moveItemInArray: it mutates the array it's given, so copy first and set a new array to keep the signal update visible (lesson 8.8). For a popover, the overlay directives keep everything in the template:
<button cdkOverlayOrigin #trigger="cdkOverlayOrigin" (click)="open.set(!open())">Filters</button>
<ng-template
cdkConnectedOverlay
[cdkConnectedOverlayOrigin]="trigger"
[cdkConnectedOverlayOpen]="open()"
(detach)="open.set(false)"
(overlayOutsideClick)="open.set(false)">
<div class="filter-panel" cdkTrapFocus>…</div>
</ng-template>
Where Angular Aria fits #
Angular Aria, stable in v22, is a set of headless directives for the WAI-ARIA patterns that are hardest to get right: tabs, menus and menubars, toolbars, listbox, select and multiselect, combobox and autocomplete, accordion, tree and grid. They handle keyboard interaction, ARIA attributes, focus management and right-to-left navigation; you supply the HTML and all of the CSS.
npm install @angular/aria
<!-- imports: [Tabs, TabList, Tab, TabPanel, TabContent] from '@angular/aria/tabs' -->
<div ngTabs>
<ul ngTabList [(selectedTab)]="selectedTab">
<li ngTab value="details">Details</li>
<li ngTab value="history">History</li>
</ul>
<div ngTabPanel value="details"><ng-template ngTabContent>…</ng-template></div>
<div ngTabPanel value="history"><ng-template ngTabContent>…</ng-template></div>
</div>
Choosing between the three:
| You need | Use |
|---|---|
| Good-looking components quickly, Material Design is acceptable | Angular Material |
| Your own design system with complex interactive widgets | Angular Aria for the widget patterns, CDK for overlays, drag-drop and scrolling |
| Low-level behaviour (positioning, focus trap, virtual scroll) inside any UI | CDK |
| Simple form inputs and buttons | Native HTML elements; they're already accessible |
The layers combine: a design system can use Aria for its tabs and menus, the CDK for its popovers, and still borrow Material's theming approach of exposing design tokens as CSS variables.
Gotchas #
| Symptom | Cause | Fix |
|---|---|---|
| Components render unstyled | No theme loaded: no prebuilt CSS file and no mat.theme() call |
Add a prebuilt theme to angular.json or include mat.theme() in global styles |
| Dark mode never activates | No color-scheme declared, so light-dark() always picks light |
Set color-scheme: light dark or toggle a class that sets dark |
| Style overrides break after upgrading | CSS targeted internal .mat-mdc-* classes |
Use theme-overrides and component *-overrides mixins |
| Theme tokens don't apply inside a custom element | Shadow DOM encapsulation blocks global theme CSS | Load theme styles in the shadow root, or use emulated encapsulation |
| Sorted list jumps back after a drop | moveItemInArray mutated the signal's array in place |
Copy the array, move, then set() the new array |
| Virtual scroll shows a blank area | Viewport has no height, or itemSize doesn't match the row height |
Give the viewport a fixed height and an accurate itemSize |
What's next #
Lesson 14.6 covers internationalization: marking text with i18n, $localize in code, extracting messages, and building per-locale versions of your app. Material components pick up locale-aware formatting from the same configuration.
Try it yourself #
Replace a prebuilt Material theme in your app with a custom mat.theme() theme using your brand colour (generated with ng generate @angular/material:theme-color), add color-scheme: light dark, and convert one hard-coded colour in a custom component to a --mat-sys-* token. Toggle your operating system's dark mode and check both.
search_documentationYes. Rules against .mat-mdc-* classes depend on Material’s internal DOM, which can change in any release. Use the token override mixins in your global styles.scss, next to mat.theme(). Each component’s Styling tab in the Material docs lists its tokens; for cards, mat.card-overrides((elevated-container-shape: 8px)) sets the corner radius, and the button documentation lists the matching shape tokens for each button style. Put the overrides on html so they apply app-wide, or on a container selector to limit them to one area. The mixins validate token names at build time, so typos fail the build. Then delete the ::ng-deep rules and check the result in both light and dark mode.Up next in Angular
More from this topic
Enjoyed this article?
Get new Angular tutorials delivered. No spam — just code-first articles when they ship.


