Material & CDK
Angular ships a first-party UI offering in two layers. Angular Material is a broad, themed, accessible component set; the CDK is the headless primitives (overlay, virtual scroll, a11y, drag-drop) those components are built on. This page exercises both, themed by the app's global Material 3 cyan theme, and then weighs them against the bespoke @foo/ui system the rest of this showcase uses.
Material components, themed
A vast, battle-tested component set with CDK and accessibility baked in — you assemble screens instead of building primitives. The cost is bundle weight and a set of Material 3 theming opinions you adopt: every control below is coloured by the global cyan M3 theme, not by this page. Reach for it when breadth and speed beat pixel-level control.
Migrating to Material 3
Everything above is themed by a single mat.theme() call in material-theme.scss — the M3 way. The legacy M2 model was different in kind: you built a theme object with mat.define-light-theme() and then applied it, component by component, through dozens of @include mat.button-theme($theme) mixins. Forget one and that component went unthemed. M3 collapses all of that into one mixin that emits a flat set of --mat-sys-* system variables on the host element; every component reads those at runtime, so there is no per-component plumbing left to forget.
// M2 — define a theme, then apply it per component
$theme: mat.define-light-theme((
color: (primary: mat.define-palette(mat.$cyan-palette)),
));
@include mat.all-component-themes($theme); // or one mixin per component
// M3 — one call emits --mat-sys-* tokens for everything
@include mat.theme((
color: (primary: mat.$cyan-palette),
typography: (plain-family: Helvetica),
density: 0,
)); What gets easier: theming is now declarative and total — density and typography are config keys on the same call, not separate mixin passes. Dark mode stops being a duplicated theme object: emit the tokens once and flip color-scheme (or, as this app does, re-run mat.theme() under a [data-theme='dark'] selector). Because the output is plain custom properties, you can read or override --mat-sys-primary from your own CSS without recompiling Sass.
The gotchas worth flagging: M3 ships only the predefined palettes ($cyan-palette and friends) — arbitrary brand hex needs a generated palette, so an exact-color migration isn't a drop-in. The token names changed wholesale, so any CSS that reached into M2 theme variables has to be rewritten against --mat-sys-*. And M3's default density and spacing run larger than M2's, which quietly reflows tight layouts — budget time to re-verify dense screens rather than assuming visual parity.
Material theming is now mandatory
There was a comfortable era when you could be sloppy about Material theming and get away with it. Drop in a prebuilt theme stylesheet — or nothing at all — and every component still rendered, styled, on its own defaults. Plenty of real apps shipped half-themed: a button mixin here, a form-field mixin there, and the dozen components nobody got around to leaning on the built-in look. It worked. That was the trap.
Modern Material took the safety net away on purpose. Components no longer carry their own default styling; they read --mat-sys-* system tokens at runtime, and the only thing that emits those tokens is a mat.theme() call. No theme call, no tokens, no styles. The component still renders — it just renders unstyled. It is the same failure mode as the @foo/ui button whose global rules never loaded: a perfectly valid element with nothing dressing it.
That changed what an upgrade actually cost. Adding a theme wasn't a checkbox — it meant authoring one complete theme (color, typography, density) and then auditing every Material component on the screen for token coverage, because anything that had been coasting on implicit defaults came back unstyled after the jump. The work wasn't writing mat.theme(); it was finding every place the old build had been quietly getting away with not theming at all.
Before / after: prebuilt theme → required mat.theme()material-theme-required.scss
Legacy leaned on a prebuilt theme (or partial per-component mixins) and rendered anyway. Modern Material reads --mat-sys-* tokens that only mat.theme() emits, so the theme has to be complete or the components come back unstyled.
/*
* Illustrative only — not compiled into the app. It shows the jump that broke legacy
* upgrades: in old Material a prebuilt theme (or no theme at all) still rendered styled
* components, so a half-themed app limped along. Modern Material reads --mat-sys-* tokens
* that ONLY mat.theme() emits, so the same half-themed app renders unstyled.
*/
/*
* --- BEFORE: legacy Material leaned on a prebuilt theme --------------------------------
* A single CSS import dressed every component; you could ship without ever calling a theme
* mixin, and partially-themed apps still looked fine because defaults filled the gaps.
*/
@import '@angular/material/prebuilt-themes/indigo-pink.css';
/*
* Some teams went a step further and themed a few components by hand, leaving the rest to
* the prebuilt defaults. That worked — until the defaults went away.
*/
@include mat.button-theme($legacy-theme);
// (mat-form-field, mat-select, mat-checkbox… were never themed, and rendered anyway)
/*
* --- AFTER: modern Material makes a complete theme mandatory ---------------------------
* mat.theme() is the ONLY thing that emits the --mat-sys-* system tokens every component
* now reads at runtime. No call, no tokens, no styles — the component renders unstyled,
* exactly like a foo-button whose global rules never loaded. So the theme has to be total:
* one call that covers color, typography, and density for the whole component set.
*/
@use '@angular/material' as mat;
html {
@include mat.theme(
(
color: (
primary: mat.$cyan-palette,
tertiary: mat.$blue-palette,
),
typography: Helvetica,
density: 0,
)
);
}
CDK virtual scroll
The viewport below is backed by 10,000 rows, but only the handful that fit on screen exist in the DOM — the rest are virtual. Scroll it: the rendered-node count stays flat while you move through ten thousand records. This is how you show large lists without paying for ten thousand elements.
Rendered DOM rows: server render of 10,000 total
CDK overlay
The overlay is the low-level primitive behind every menu, tooltip, and dialog — it positions a floating panel relative to a trigger, manages the backdrop, and handles outside-click and detach. Here it's wired declaratively with cdkConnectedOverlay; the panel itself is a plain token-styled card, so nothing about its look depends on Material.
Material vs a custom design system
There is no single right answer — this showcase deliberately demonstrates both. Material buys you breadth and proven behaviour fast; a bespoke token system like @foo/ui buys you total control and a small surface. The choice is about team size, timeline, and how much the visual identity matters.
Reach for Material when…
- You need breadth now — dozens of components, already built and tested.
- Accessibility and keyboard behaviour must be right without authoring it yourself.
- You want the CDK primitives (overlay, virtual scroll, drag-drop, a11y) anyway.
- A large team benefits from a documented, conventional, widely-known API.
- The Material 3 look is acceptable, or close enough after light theming.
Build a bespoke system (@foo/ui) when…
- The brand demands full visual control with no theming engine to fight.
- You want a minimal, intentional surface — only the components you actually use.
- Bundle size matters; you ship tokens and a few components, not a framework.
- You value owning the markup and a11y semantics end-to-end.
- The system is small enough that maintaining it is cheaper than bending Material.