Or: How Google Finally Gave Us The Token System We’ve Been Waiting For
Remember our last chat about CSS Variables as Design Tokens? Well, grab your favourite caffeinated beverage because we’re about to dive into how Angular Material’s evolution since version 15.0.0 took that concept and ran with it like a caffeinated developer who just discovered a new framework.

💡 CSS-First Approach: This article focuses exclusively on pure CSS solutions using Angular Material’s theme-color schematic. While Sass is available, CSS custom properties provide all the theming power you need with zero build complexity!
If you’ve been working with Angular Material for a while, you’ve probably felt the pain of trying to customize Material components without feeling like you’re wrestling with a particularly stubborn CSS specificity monster. Those days are officially over, my friend.
The Great Token Awakening: Angular Material’s Design System Revolution
Starting with Angular Material 15.0.0 (November 2022), Google introduced something that made frontend developers everywhere shed a single, beautiful tear: proper design token support. The Angular team finally embraced the power of CSS custom properties and built them directly into Material’s theming system, reaching full stability in version 18.0.0 (May 2024).
But this wasn’t just slapping some CSS variables on existing components and calling it a day. Oh no, they went full send and redesigned the entire theming architecture around the Material Design 3 specification which is basically Google’s love letter to design tokens.
What Changed (And Why You Should Care)
Before, customizing components felt like this:
// 😩 The old way - specificity wars and tears
.mat-button {
background-color: #your-brand-color !important; // *shudder*
&.mat-button-disabled {
background-color: #some-gray !important; // *double shudder*
}
}Now it feels like this:
/* 🎉 The new way - elegant and maintainable */
:root {
--mat-sys-primary: #your-brand-color;
--mat-sys-on-primary: #ffffff;
}
/* That's it. Seriously. */The components just… work. No specificity battles, no `!important` declarations, no late-night CSS debugging sessions questioning your life choices.
Understanding Material 3’s Token System
Material Design 3 introduced a sophisticated token system that Angular Material implements beautifully (introduced in 15.0.0, stable in 18.0.0). Let’s break it down:
System Tokens (The Foundation)
These are your base colors that everything else derives from:
:root {
/* Primary colors - your brand identity */
--mat-sys-primary: #6750A4;
--mat-sys-on-primary: #FFFFFF;
--mat-sys-primary-container: #EADDFF;
--mat-sys-on-primary-container: #21005D;
/* Secondary colors - supporting your primary */
--mat-sys-secondary: #625B71;
--mat-sys-on-secondary: #FFFFFF;
--mat-sys-secondary-container: #E8DEF8;
--mat-sys-on-secondary-container: #1D192B;
/* Surface colors - backgrounds and containers */
--mat-sys-surface: #FFFBFE;
--mat-sys-on-surface: #1C1B1F;
--mat-sys-surface-variant: #E7E0EC;
--mat-sys-on-surface-variant: #49454F;
/* Utility colors */
--mat-sys-error: #BA1A1A;
--mat-sys-on-error: #FFFFFF;
--mat-sys-outline: #79747E;
--mat-sys-shadow: #000000;
}Component Tokens (The Magic Layer)
These tokens are specific to individual components and automatically use the system tokens:
:root {
/* Button tokens */
--mat-filled-button-container-color: var(--mat-sys-primary);
--mat-filled-button-label-text-color: var(--mat-sys-on-primary);
--mat-filled-button-disabled-container-color: rgba(var(--mat-sys-on-surface), 0.12);
/* Card tokens */
--mat-card-container-color: var(--mat-sys-surface-container-low);
--mat-card-outlined-outline-color: var(--mat-sys-outline);
/* Form field tokens */
--mat-form-field-container-text-color: var(--mat-sys-on-surface);
--mat-form-field-focus-select-arrow-color: var(--mat-sys-primary);
}The beautiful thing? You rarely need to touch component tokens directly. Change a system token, and everything that uses it updates automatically.
Getting Started: Creating Your Theme with Angular CLI
The Angular team made getting started ridiculously easy. Here’s how to generate a proper theme file:
# Generate a new theme file with custom colors
ng generate @angular/material:theme-color --primary-color=#663399
# Generate with multiple custom colors and options
ng generate @angular/material:theme-color \
--directory=src/assets/themes \
--primary-color=#663399 \
--secondary-color=#FFC000 \
--tertiary-color=#4CAA50 \
--neutral-color=#9E9E9E \
--neutral-variant-color=#607D8B \
--error-color=#F44444 \
--include-high-contrast \
--is-scss=false # Generate CSS instead of SassThis creates a comprehensive theme file ready to use!
CSS-First Approach: The Complete Generated Structure
Note: This article focuses exclusively on CSS output (`--is-scss=false`) since CSS custom properties provide all the power we need without additional build complexity.
When you generate a theme with `--is-scss=false`, Angular Material creates a comprehensive `theme.css` file with five complete system categories:
/* Generated file: theme.css */
html {
/* COLOR SYSTEM VARIABLES */
color-scheme: light dark;
/* Primary palette variables */
--mat-sys-primary: light-dark(#5236ab, #cbc3e6);
--mat-sys-on-primary: light-dark(#ffffff, #3a2679);
--mat-sys-primary-container: light-dark(#ebeef2, #4c2fa5);
--mat-sys-on-primary-container: light-dark(#200a58, #ebeef2);
--mat-sys-inverse-primary: light-dark(#cbc3e6, #5236ab);
--mat-sys-primary-fixed: light-dark(#5236ab, #5236ab);
--mat-sys-primary-fixed-dim: light-dark(#cbc3e6, #cbc3e6);
--mat-sys-on-primary-fixed: light-dark(#ffffff, #ffffff);
--mat-sys-on-primary-fixed-variant: light-dark(#4c2fa5, #4c2fa5);
/* Secondary + Tertiary + Error palettes... */
--mat-sys-secondary: light-dark(#e31937, #fce8eb);
--mat-sys-tertiary: light-dark(#ac2868, #ffb0cb);
--mat-sys-error: light-dark(#bc1127, #ffb3af);
/* Neutral/Surface system variables */
--mat-sys-background: light-dark(#f9fafb, #151515);
--mat-sys-surface: light-dark(#f9fafb, #151515);
--mat-sys-surface-container: light-dark(#eeeeee, #242424);
--mat-sys-surface-container-high: light-dark(#e8e8e8, #2e2e2e);
--mat-sys-on-surface: light-dark(#1c1c1c, #efefef);
--mat-sys-outline: light-dark(#767676, #a8a8a8);
--mat-sys-outline-variant: light-dark(#c0c0c0, #5c5c5c);
/* TYPOGRAPHY SYSTEM VARIABLES */
--mat-sys-brand-font-family: "Source Sans Pro";
--mat-sys-plain-font-family: "Source Sans Pro";
--mat-sys-bold-font-weight: 700;
--mat-sys-medium-font-weight: 500;
--mat-sys-regular-font-weight: 400;
/* Typescale variables */
--mat-sys-body-large: var(--mat-sys-body-large-weight) var(--mat-sys-body-large-size) / var(--mat-sys-body-large-line-height) var(--mat-sys-body-large-font);
--mat-sys-body-large-font: var(--mat-sys-plain-font-family);
--mat-sys-body-large-size: 1rem;
--mat-sys-body-large-line-height: 1.5rem;
--mat-sys-body-large-weight: var(--mat-sys-regular-font-weight);
--mat-sys-headline-large: var(--mat-sys-headline-large-weight) var(--mat-sys-headline-large-size) / var(--mat-sys-headline-large-line-height) var(--mat-sys-headline-large-font);
--mat-sys-headline-large-font: var(--mat-sys-brand-font-family);
--mat-sys-headline-large-size: 2rem;
--mat-sys-headline-large-line-height: 2.5rem;
/* ELEVATION SYSTEM VARIABLES */
--mat-sys-umbra-color: color-mix(in srgb, var(--mat-sys-shadow), transparent 80%);
--mat-sys-penumbra-color: color-mix(in srgb, var(--mat-sys-shadow), transparent 86%);
--mat-sys-ambient-color: color-mix(in srgb, var(--mat-sys-shadow), transparent 88%);
--mat-sys-level0: 0px 0px 0px 0px var(--mat-sys-umbra-color);
--mat-sys-level1: 0px 2px 1px -1px var(--mat-sys-umbra-color), 0px 1px 1px 0px var(--mat-sys-penumbra-color), 0px 1px 3px 0px var(--mat-sys-ambient-color);
--mat-sys-level2: 0px 3px 3px -2px var(--mat-sys-umbra-color), 0px 3px 4px 0px var(--mat-sys-penumbra-color), 0px 1px 8px 0px var(--mat-sys-ambient-color);
/* SHAPE SYSTEM VARIABLES */
--mat-sys-corner-small: 8px;
--mat-sys-corner-medium: 12px;
--mat-sys-corner-large: 16px;
--mat-sys-corner-extra-large: 28px;
--mat-sys-corner-full: 9999px;
/* STATE SYSTEM VARIABLES */
--mat-sys-hover-state-layer-opacity: 0.08;
--mat-sys-focus-state-layer-opacity: 0.12;
--mat-sys-pressed-state-layer-opacity: 0.12;
--mat-sys-dragged-state-layer-opacity: 0.16;
/* HIGH CONTRAST SUPPORT */
@media (prefers-contrast: more) {
--mat-sys-primary: light-dark(#30028a, #f4edff);
--mat-sys-on-primary: light-dark(#ffffff, #000000);
--mat-sys-outline: light-dark(#2c2c2c, #f2efef);
/* Enhanced contrast versions of all color variables... */
}
}Using Your Generated Theme: Pure CSS Power
The beauty of the CSS approach is zero build complexity. Just link your generated `theme.css` file and start using the tokens:
<!-- Link your generated theme -->
<link rel="stylesheet" href="./assets/themes/theme.css">/* Use the tokens immediately in your components */
.my-custom-card {
background-color: var(--mat-sys-surface-container);
color: var(--mat-sys-on-surface);
border-radius: var(--mat-sys-corner-large);
box-shadow: var(--mat-sys-level2);
font: var(--mat-sys-body-large);
}
.my-primary-button {
background-color: var(--mat-sys-primary);
color: var(--mat-sys-on-primary);
border-radius: var(--mat-sys-corner-small);
}
.my-primary-button:hover {
background-color: color-mix(in srgb,
var(--mat-sys-primary) calc(100% - var(--mat-sys-hover-state-layer-opacity) * 100%),
var(--mat-sys-on-primary) calc(var(--mat-sys-hover-state-layer-opacity) * 100%)
);
}The generated system automatically handles:
- 🌓 Light/Dark Mode: All tokens use `light-dark()` function
- ♿ High Contrast: Automatic contrast enhancement via `@media (prefers-contrast: more)`
- 🎨 Color Harmony: Scientifically generated color palettes with proper contrast ratios
- 📝 Typography Scale: Complete typographic hierarchy from display to body text
- 📦 Elevation System: Ready-to-use box shadows for Material Design depth
- 🔄 Shape System: Consistent border radius tokens for all use cases
- ⚡ State Management: Opacity tokens for hover, focus, and press states
This is way better than the old approach where you had to manage separate light and dark theme files!
Building Your Own Semantic Layer
Now here’s where we can combine Angular Material’s tokens with our own semantic design system from our previous article:
:root {
/* Material's system tokens (auto-generated with light-dark function) */
--mat-sys-primary: light-dark(#5236ab, #cbc3e6);
--mat-sys-on-primary: light-dark(#ffffff, #3a2679);
--mat-sys-surface: light-dark(#f9fafb, #151515);
--mat-sys-surface-container: light-dark(#eeeeee, #242424);
/* Your semantic tokens - building on Material's foundation */
--brand-primary: var(--mat-sys-primary);
--brand-primary-text: var(--mat-sys-on-primary);
--surface-primary: var(--mat-sys-surface);
--surface-elevated: var(--mat-sys-surface-container);
/* Component-specific semantic tokens */
--header-background: var(--brand-primary);
--header-text: var(--brand-primary-text);
--content-background: var(--surface-primary);
--card-background: var(--surface-elevated);
/* Custom spacing that works with Material's density */
--spacing-xs: 8px;
--spacing-sm: 12px;
--spacing-md: 16px;
--spacing-lg: 24px;
--spacing-xl: 32px;
}Understanding Build-Time vs Runtime Behavior
Important: Angular Material’s generated CSS files contain pre-calculated, final color values — not dynamic calculations. When you generate a theme, all color relationships are computed at build time and baked into static CSS.
/* Generated theme.css - These are FINAL, pre-calculated values */
html {
--mat-sys-primary: light-dark(#663399, #c9a7ff);
--mat-sys-on-primary: light-dark(#ffffff, #3a0066);
--mat-sys-primary-container: light-dark(#e9ddff, #5700cc);
/* These relationships were calculated ONCE during generation */
/* Changing --mat-sys-primary won't recalculate the others */
}What This Means for Runtime Customization:
// ❌ This WON'T work as expected
export class ColorCustomizationService {
updatePrimaryColor(hexColor: string): void {
// This only changes the primary color
document.documentElement.style.setProperty('--mat-sys-primary', hexColor);
// But --mat-sys-primary-container, --mat-sys-on-primary, etc.
// stay the same! No automatic recalculation happens.
}
}Better Approaches for User Customization:
- Pre-generate Multiple Themes: Create several theme variants at build time
- Theme Switching: Allow users to choose between pre-defined themes
Dark Theme: The Plot Twist That Actually Works
Remember how dark themes used to be a nightmare of CSS overrides? Angular Material’s CSS approach makes it almost boring how easy it is:
/* Your generated theme.css already handles light/dark automatically! */
html {
color-scheme: light dark; /* This tells the browser to respect user preferences */
/* All tokens use light-dark() function - they switch automatically */
--mat-sys-primary: light-dark(#5236ab, #cbc3e6);
--mat-sys-surface: light-dark(#f9fafb, #151515);
--mat-sys-on-surface: light-dark(#1c1c1c, #efefef);
/* ... all other tokens automatically adapt */
}That’s it. Seriously. No theme switching logic, no duplicate color definitions, no manual dark mode overrides.
And just like that, all your Material components automatically switch to dark mode when you toggle the theme:
// theme.service.ts - Because we're fancy like that
@Injectable({
providedIn: 'root'
})
export class ThemeService {
private isDark = signal(false);
toggleTheme(): void {
this.isDark.update(dark => !dark);
const theme = this.isDark() ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', theme);
}
get currentTheme(): string {
return this.isDark() ? 'dark' : 'light';
}
}Important Considerations & Realistic Patterns
Responsive Design with Material Tokens
Reality Check: Material components use their own specific tokens, not custom variables. However, you can build responsive patterns for your custom components:
/* ✅ Your custom components can be responsive */
.my-custom-card {
/* Use Material's actual tokens */
background-color: var(--mat-sys-surface-container);
border-radius: var(--mat-sys-corner-large);
padding: var(--mat-sys-spacing-md, 16px);
}
@media (max-width: 768px) {
.my-custom-card {
/* Adjust your custom components */
border-radius: var(--mat-sys-corner-small);
padding: var(--mat-sys-spacing-sm, 12px);
}
}
/* ❌ Material components don't use custom --density-scale */
/* They have their own internal density system that we can't hook into */
/* ✅ But you can build your own density system for custom components */
:root {
--app-density-factor: 0; /* -2 = compact, 0 = normal, 3 = loose */
--my-component-height: calc(48px + var(--app-density-factor) * 4px);
}
.my-density-component {
height: var(--my-component-height);
background-color: var(--mat-sys-primary);
}> 📖 Coming Up Next: In our next article, we’ll build a custom density system from scratch! You’ll learn how to create density-aware components with smooth transitions, global density controls, and a cohesive design system.
Custom Component Integration
Building custom components that play nicely with Material’s tokens:
@Component({
selector: 'app-status-chip',
template: `
<div class="status-chip" [attr.data-status]="status">
<mat-icon>{{ getIcon() }}</mat-icon>
<span>{{ label }}</span>
</div>
`,
styles: [`
.status-chip {
/* Base styling using Material tokens */
--chip-background: rgb(var(--mat-sys-surface-container-high));
--chip-text: rgb(var(--mat-sys-on-surface));
--chip-padding: var(--spacing-sm) var(--spacing-md);
--chip-border-radius: 16px;
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
padding: var(--chip-padding);
border-radius: var(--chip-border-radius);
background-color: var(--chip-background);
color: var(--chip-text);
transition: all 200ms ease;
}
.status-chip[data-status="success"] {
--chip-background: rgb(var(--mat-sys-primary-container));
--chip-text: rgb(var(--mat-sys-on-primary-container));
}
.status-chip[data-status="error"] {
--chip-background: rgb(var(--mat-sys-error-container));
--chip-text: rgb(var(--mat-sys-on-error-container));
}
.status-chip[data-status="warning"] {
--chip-background: rgb(var(--mat-sys-tertiary-container));
--chip-text: rgb(var(--mat-sys-on-tertiary-container));
}
`]
})
export class StatusChipComponent {
@Input() status: 'success' | 'error' | 'warning' | 'info' = 'info';
@Input() label = '';
getIcon(): string {
const icons = {
success: 'check_circle',
error: 'error',
warning: 'warning',
info: 'info'
};
return icons[this.status];
}
}Common Gotchas (And How to Avoid Them)
Color-Scheme Must Be Set for Light-Dark to Work
The Issue: The generated CSS uses `light-dark()` functions, but they default to light mode if `color-scheme` isn’t properly set.
html {
color-scheme: light; /* Only light mode works! */
--mat-sys-primary: light-dark(#7845ac, #dcb8ff); /* Dark value ignored */
}
/* ✅ Fix: Update color-scheme for automatic switching */
html {
color-scheme: light dark; /* Now both modes work */
}
/* ✅ Or control it programmatically */
html[data-theme="dark"] {
color-scheme: dark;
}
html[data-theme="light"] {
color-scheme: light;
}Browser Support Considerations
The Issue: While `light-dark()` has excellent modern browser support, some projects may need to consider legacy compatibility.
Current Support:
- Chrome 123+ (March 2024)
- Safari 17.5+ (May 2024)
- Firefox 120+ (November 2023)
- Edge 123+ (March 2024)
Token Specificity and Override Issues
The Issue: Angular Material’s tokens are defined on `html`, but your styles might not inherit properly due to CSS specificity.
/* ❌ This might not work as expected */
.my-card .mat-mdc-button {
background-color: var(--mat-sys-secondary); /* Might be overridden */
}
/* ❌ Material's internal styles have higher specificity */
.mat-mdc-button.mat-primary {
background-color: var(--mat-filled-button-container-color); /* Wins */
}
/* ✅ Use CSS custom properties correctly */
.my-card {
--mat-filled-button-container-color: var(--mat-sys-secondary);
}
/* ✅ Or increase specificity appropriately */
.my-card .mat-mdc-button.mat-primary {
background-color: var(--mat-sys-secondary) !important; /* Last resort */
}Missing High Contrast Styles
The Issue: If you generated with `--include-high-contrast`, the styles are there but might not work as expected.
/* ✅ High contrast styles are automatically included */
@media (prefers-contrast: more) {
/* Enhanced contrast versions of all colors */
--mat-sys-primary: light-dark(#470c7a, #f6ebff);
--mat-sys-on-primary: light-dark(#ffffff, #000000);
}
/* ❌ But you need to test them! */
/* Use Chrome DevTools > Rendering > Emulate CSS media > prefers-contrast: more */Testing High Contrast:
- Chrome DevTools → Rendering → “Emulate CSS media features” → `prefers-contrast: more`
- Windows: Settings → Accessibility → Contrast themes
- macOS: System Preferences → Accessibility → Display → Increase contrast
Performance: Tokens Don’t Just Look Good, They Perform Good
One of the underrated benefits of Angular Material’s token system is performance. Instead of recalculating styles, the browser just swaps CSS custom property values. This makes theme switching, hover effects, and state changes significantly faster.
/* Before: Browser recalculates entire ruleset */
.mat-button:hover {
background-color: #1976D2;
color: #FFFFFF;
box-shadow: 0 2px 4px rgba(0,0,0,0.12);
}
/* After: Browser just updates custom property values */
.mat-mdc-button:hover {
--mat-filled-button-container-color: rgb(var(--mat-sys-primary-hover));
}The Future is Bright (And Properly Tokenized)
Angular Material’s embrace of design tokens isn’t just about making theming easier (though it absolutely does that). It’s about creating a more maintainable, scalable, and delightful development experience.
We’re moving toward a future where:
- Design systems are truly systematic
- Components are inherently themeable
- Customization doesn’t require CSS warfare
- Performance and maintainability go hand in hand
Wrapping Up: Your Token-Powered Journey Begins
Angular Material’s design token system is more than just a new theming approach — it’s a paradigm shift toward better component architecture. Combined with the semantic design token concepts from our previous article, you now have the tools to build interfaces that are:
- Consistent across your entire application
- Maintainable without the usual CSS debt
- Themeable without breaking a sweat
- Performant by design
- Future-proof regardless of framework trends
So go forth, generate those themes, embrace those tokens, and build interfaces that make both designers and developers smile. Your future self (and your teammates, and your users) will thank you for it.
And remember — in a world where CSS specificity wars are finally over, we’re all winners.
Keep tokenizing, Angular style! 🚀