Angular Material’s Design Token Evolution: A Match Made in Component Heaven

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 Sass

This 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:

  1. Pre-generate Multiple Themes: Create several theme variants at build time
  2. 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! 🚀

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top