Angular Material M3 dynamic runtime colors

Raul Rothschild
12 min readMay 20, 2024

--

TL;DR — This article dives into how to implement code that overrides custom properties whenever a source color is chosen. This allows you to dynamically change the Material M3 color scheme at runtime. You can access the source code directly or learn more about new Angular Material M3 themes through this reading.

Everybody who has worked with Angular Material versions 14 or below has probably noticed how complicated it can be to style the components provided by the framework. There is an entire guide in the documentation for defining custom CSS rules that directly style Angular Material components. One of the major problems not explicitly mentioned in the guide, but seen over time, is the high level of _specificity_ and _selectors_ used by the framework by default. Bypassing encapsulation of deeply nested CSS classes often ends up being a mix of ::ng-deep , !important, and a bit of lucky to achieve a simple modification without breaking everything.

Fortunately, accordingly to the docs this behavior has changed.

[Docs] Migrating to MDC-based Angular Material Components

In Angular Material v15 and later, many of the components have been refactored to be based on the official Material Design Components for Web (MDC)

Since I believe most of the production applications in the wild aren’t using the latest Angular versions, and considering that Material 3 theming is still experimental and supported by Angular Material 17.2 features, the benefits may go unnoticed by the community. One of the benefits, which is the focus of this article, is the ability to add runtime Angular Material theming colors.

Creating a new angular app

Run the command `ng n runtime-angular-material` and chose `scss` as your default stylesheet and no SSR.

Which stylesheet format would you like to use? 
❯ SCSS [ https://sass-lang.com/documentation/syntax#scss

Do you want to enable Server-Side Rendering (SSR) ?
❯ No

Lets add Angular Material by running the command `ng add @angular/material`

✔ Packages successfully installed.

Choose a prebuilt theme name, or "custom" for a custom theme:
❯ Custom

Set up global Angular Material typography styles?
❯ Yes

Include the Angular animations module?
❯ Include and enable animations

And the last and not least, through your pkg manager install Angular Experimental by running `npm i @angular/material-experimental`

Creating a “custom” Angular Material M3 Theme

As pointed at the documentation Migrating to MDC-based Components guide, the solution here purposed may change in the near future as the Angular Material team evolves the framework.

[Docs] What Is Material 3

As of v17.2.0, Angular Material includes experimental support for M3 styling in addition to M2. The team plans to stabilize support for M3 after a brief period in experimental in order to get feedback on the design and API.

And at the end of the guide there also a FAQ.

[Docs] FAQ

Can I use colors other than the pre-defined Material 3 palettes?

- Currently, we only offer predefined palettes, but we plan to add support for using custom generated palettes as part of making the M3 APIs stable and available in @angular/material.

When looking at the source code schema for `predefined palettes` you can define your own by copying them from `@forward ‘./theming/m3-palettes’` inside `_index.scss` at your `node_modules/@angular/material-experimental`.

At your styles.scss copy/paste a predefined theme and later on at this article we will generate a compatible M3 Theme using @material/material-color-utilities an official library for color utilities from Google Material team.

//styles.scss
@use 'sass:map';
@use '@angular/material' as mat;
@use '@angular/material-experimental' as matx;

@include mat.core();

$m3-base-config: (
color: (
// Start of copying the $cyan-palette into PRIMARY color config
primary: (
0: #000000,
10: #002020,
20: #003737,
25: #004343,
30: #004f4f,
35: #005c5c,
40: #006a6a,
50: #008585,
60: #00a1a1,
70: #00bebe,
80: #00dddd,
90: #00fbfb,
95: #adfffe,
98: #e2fffe,
99: #f1fffe,
100: #ffffff,
secondary: ( // The pattern follows
...
),
neutral: ( // by only chaniging source color
...
),
neutral-variant: (// and the the tone
...
),
error: ( // for each TonalPallete
...
)
//End of copying the $cyan-palette as config for a primary color

// For our "secondary" color we will apply directly
// an aleready built in M3 theme palette
tertiary: matx.$m3-violet-palette,

),

typography: () // [Optional] Typography config for M3,
density: () // [Optional] Density config config for M3
)

//Using matx the new api for defining a M3 Theme
$angular-material-3-theme: matx.define-theme(
map.set($m3-base-config, color, theme-type, light)
);

$angular-material-3-theme-dark: matx.define-theme(
map.set($m3-base-config, color, theme-type, dark)
);

html,
body {
height: 100%;
margin: 0;
// Apply the theme for all components. Matx is only for theme
// @mat.core() still the API for applying the styles
@include mat.all-component-themes(theme.$angular-material-3-theme);
}

Now you might have Angular Material Components using M3 $cyan-palette and if copy and paste some components from the documentation you will see something similar to this.

Is noteworthy that all buttons have the same color, even copy pasting from the button documentation section example. This is also mentioned at Migrating to MDC-based Angular Material Components guide.

[Docs] Using component color variants

A number of components have a `color` input property that allows developers to apply different color variants of the component. When using an M3 theme, this input still adds a CSS class to the component (e.g. `.mat-accent`).

However, there are no built-in styles targeting these classes. You can instead apply color variants by passing the `$color-variant` option to a component’s `-theme` or `-color` mixins.

So, if you want to still use the old way to apply components colors you can add to your style.scss the compatibility mixin.

// style.scss
html,
body {
...
@include matx.color-variants-back-compat($angular-material-3-theme);
}

And now everything may looks familiar again.

Understanding Angular Material M3 Theme changes based on Design tokens

Starting from Angular Material v15, M3 design tokens are now represented by custom properties and referenced as CSS variables instead of multiple CSS classes. By inspecting a `mat-flat-button`, we can observe this change.

//A lot of tokens for the mdc button
.mat-primary.mat-mdc-button-base {
--mdc-text-button-label-text-color: #005cbb;
--mdc-text-button-disabled-label-text-color: rgba(26, 27, 31, .38);
--mdc-protected-button-container-color: #fdfbff;
--mdc-protected-button-label-text-color: #005cbb;
...
}
// And some class using it
.mat-mdc-unelevated-button {
font-family: var(--mdc-filled-button-label-text-font);
font-size: var(--mdc-filled-button-label-text-size);
letter-spacing: var(--mdc-filled-button-label-text-tracking);
font-weight: var(--mdc-filled-button-label-text-weight);
text-transform: var(--mdc-filled-button-label-text-transform);
height: var(--mdc-filled-button-container-height);
border-radius: var(--mdc-filled-button-container-shape);
padding: 0 var(--mat-filled-button-horizontal-padding, 16px);
}

If you inspect a little more, you may find the specific custom property that is defining the style you would like to change. Here is another example for `mat-flat-button`.

// The token somewhere
html, body {
...
--mdc-filled-button-container-color: #007dff;
...
}

// The actual classe generated from
// mat.all-component-themes(theme.$angular-material-3-theme)
.mat-mdc-unelevated-button:not(:disabled) {
background-color: #007dff;
}

Unfortunately, Angular Material design token names differ slightly from those of MDC Web Components and do not specify which tokens correspond to each CSS class or component. Furthermore, unlike the Material Web Components Color Tokens documentation, Angular Material does not provide a set of generic design tokens to change the entire theme at once. For changing colors dynamically at runtime, manually inspecting each token and overriding it using a CSS variable is not ideal because you don’t know the exact tone, shade, or function used to generate a specific token color.

However, after some research, I found a library called @material-color-utilities, which is a recently open-sourced library from Google that came along with their latest version of Material Design (M3). This library helps generate a theme from a source color, similar to their theme builder example, which you can check out at Material 3 Theme Builder.

Theming Angular Material 3 components with custom css properties

In your style.scss, instead of declaring hard-coded HEX values for primary and tertiary colors directly, we will create custom properties for each tone that can be generated from material-color-utilities and referenced as variables at the M3 Theme configuration. Later on, we will override these variables at the root of the application.

// Super fancy custom properties names
:root {
--primary-0: #000000;
--primary-10: #001f24;
--primary-20: #00363d;
--primary-25: #00424a;
--primary-30: #004f58;
--primary-35: #005b66;
--primary-40: #006874;
--primary-50: #068391;
--primary-60: #389eac;
--primary-70: #58b9c7;
--primary-80: #75d4e4;
--primary-90: #98f0ff;
--primary-95: #d0f8ff;
--primary-98: #edfcff;
--primary-99: #f6feff;
--primary-100: #ffffff;

--p-secondary-0: #000000;
--p-secondary-10: #001f24;
--p-secondary-20: #00363d;
--p-secondary-25: #00424a;
--p-secondary-30: #004f58;
--p-secondary-35: #005b66;
--p-secondary-40: #006874;
--p-secondary-50: #068391;
--p-secondary-60: #389eac;
--p-secondary-70: #58b9c7;
--p-secondary-80: #75d4e4;
--p-secondary-90: #98f0ff;
--p-secondary-95: #d0f8ff;
--p-secondary-98: #edfcff;
--p-secondary-99: #f6feff;
--p-secondary-100: #ffffff;

--p-neutral-0: #000000;
--p-neutral-10: #191c1c;
--p-neutral-20: #2e3131;
--p-neutral-25: #393c3c;
--p-neutral-30: #454748;
--p-neutral-35: #505353;
--p-neutral-40: #5c5f5f;
--p-neutral-50: #757778;
--p-neutral-60: #8f9191;
--p-neutral-70: #aaabac;
--p-neutral-80: #c5c7c7;
--p-neutral-90: #e1e3e3;
--p-neutral-95: #f0f1f1;
--p-neutral-98: #f9f9fa;
--p-neutral-99: #fbfcfc;
--p-neutral-100: #ffffff;

--p-neutral-variant-0: #000000;
--p-neutral-variant-10: #161d1e;
--p-neutral-variant-20: #2b3233;
--p-neutral-variant-25: #363d3e;
--p-neutral-variant-30: #414849;
--p-neutral-variant-35: #4d5455;
--p-neutral-variant-40: #586061;
--p-neutral-variant-50: #71787a;
--p-neutral-variant-60: #8b9293;
--p-neutral-variant-70: #a5acae;
--p-neutral-variant-80: #c1c8c9;
--p-neutral-variant-90: #dde4e5;
--p-neutral-variant-95: #ebf2f3;
--p-neutral-variant-98: #f4fbfc;
--p-neutral-variant-99: #f7fdff;
--p-neutral-variant-100: #ffffff;

--error-0: #000000;
--error-10: #410002;
--error-20: #690005;
--error-25: #7e0007;
--error-30: #93000a;
--error-35: #a80710;
--error-40: #ba1a1a;
--error-50: #de3730;
--error-60: #ff5449;
--error-70: #ff897d;
--error-80: #ffb4ab;
--error-90: #ffdad6;
--error-95: #ffedea;
--error-98: #fff8f7;
--error-99: #fffbff;
--error-100: #ffffff;

}

//
$m3-base-config: (
color: (
//Redefine the primary color based on your own css vars
primary: (
0: #000000, //
10: var(--primary-10),
20: var(--primary-20),
25: var(--primary-25),
30: var(--primary-30),
35: var(--primary-35),
40: var(--primary-40),
50: var(--primary-50),
60: var(--primary-60),
70: var(--primary-70),
80: var(--primary-80),
90: var(--primary-90),
95: var(--primary-95),
98: var(--primary-98),
99: var(--primary-99),
100: #ffffffff,
secondary: (
0: #000000,
10: var(--p-secondary-10),
20: var(--p-secondary-20),
...
),
neutral: (
0: #000000,
10: var(--p-neutral-10),
20: var(--p-neutral-20),
...
100: #ffffffff,
),
neutral-variant: (
0: #000000,
10: var(--p-neutral-variant-10),
20: var(--p-neutral-variant-20),
...
100: #ffffffff,
),
error: (
0: #000000,
10: var(--error-10),
20: var(--error-20),
...
100: #ffffffff,
)
),
// You can do the same for you "secondary" color
// which they call tertiary
tertiary: (
...
)
)

By doing this, Angular Material’s internal mixins will create CSS variables that point to your CSS variables, resulting in an unexpected pointer of a pointer. If everything is done correctly, you should not notice any changes in your styles, only in the generated variables following the tokens from Material 3.

If you inspect your components tokens you might see:


//A lot of tokens for the mdc button pointing to your color vars
html, body {
--mdc-text-button-label-text-color: #005cbb;
--mdc-text-button-disabled-label-text-color: rgba(26, 27, 31, .38);
--mdc-filled-button-container-color: var(--primary-40);
--mdc-protected-button-label-text-color: #005cbb;
--mdc-fab-container-color: var(--primary-90);

--mat-fab-small-foreground-color: var(--primary-10);
--mat-fab-small-state-layer-color: var(--primary-10);
--mat-fab-small-ripple-color: var(--primary-10);

--mat-datepicker-calendar-period-button-text-color: var(--p-neutral-variant-30);
--mat-datepicker-calendar-period-button-icon-color: var(--p-neutral-variant-30);
--mat-datepicker-calendar-navigation-button-icon-color: var(--p-neutral-variant-30);
--mat-datepicker-calendar-header-text-color:
...
}

Some custom properties may not change because they are not correlated with the theme colors configuration. However, now, you can at least more easily check which palette/tone is being used in all Material components and combine it with M2 utility functions.

[Docs] Theme your own components using a Material 3 theme

The same utility functions for reading properties of M2 themes (described in our guide for theming your components) can be used to read properties from M3 themes. However, the named palettes, typography levels, etc. available are different for M3 themes, in accordance with the spec.

For example, if you want to set a component’s background to your secondary color, consider using the `S-90` color token. For text colors within this component background, use the `S-10` color token. Alternatively, you can automate this by using the utility functions and passing the `$role` as `secondary-container` and `on-secondary-container`, as suggested by the theme builder.

Here is a code example:

//_button-theme.scss
@use 'sass:map';
@use '@angular/material' as mat;
@use './theme' as theme; //just a file where theme is set

@include mat.core();

.primary-button {
color: mat.get-theme-color(
theme.$angular-material-3-theme,
inverse-on-surface // will generate a color based on css var
);
background-color: mat.get-theme-color(
theme.$angular-material-3-theme,
primary,
70 // It will points to your custom css var token :)
);
border-radius: 10px;
}

You can have a better understanding how to theme your components based at Material 3 Theme Builder where you can check which color or role to use.

Applying a dynamic runtime theme

Now it’s time to implement the code that will override the custom properties when a source color is selected. The code snippets below provide a brief idea of how to achieve the runtime goal of changing Material M3 colors. I strongly recommend looking at the source code here.

// app.component.ts

export const DEFAULT_COLOR = '#8714fa';

export class AppComponent {
color = signal(DEFAULT_COLOR);

onColorChange(event: any) {
this.color.set(event.value);
}
}

At app.compontent.html lets add some code for a color picker input

// app.component.html

<div class="theme-controls">
<div class="color-picker-wrapper">
<div class="color-picker-overflow">
<input
matInput
id="color-input"
type="color"
[(ngModel)]="color"
(ngModelChange)="onColorChange($event)"
/>
</div>
</div>
</div>

Lets have some styles

// app.component.scss

.theme-controls {
display: flex;
gap: 0.65rem;
align-items: center;
margin: auto;

.color-picker-wrapper {
display: flex;
width: 40px;
height: 40px;
border-radius: 50%;
position: relative;

.color-picker-overflow {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
overflow: hidden;
border-radius: 50%;

#color-input {
cursor: pointer;
border: none;
background: none;
min-width: 150%;
min-height: 150%;
}
}
}
}

Now we are going to use @material-color-utilities library o generate a M3 theme from the selected color

themeFromSelectedColor(color?: string, isDark?: boolean): void {
// All calculations are made using numbers
// we need HEX strings for use @material-utilitis-color apis
const theme = themeFromSourceColor(
argbFromHex(this.color() ?? DEFAULT_COLOR)
);

// ngular material tones
const tones = [0, 10, 20, 25, 30, 35, 40, 50, 60, 70, 80, 90, 95, 99, 100];

// A colors Dictionary
const colors = Object.entries(theme.palettes).reduce(
(acc: any, curr: [string, TonalPalette]) => {

const hexColors = tones.map((tone) => ({ tone, hex:
hexFromArgb(curr[1].tone(tone)),
}));

return { ...acc, [curr[0]]: hexColors };
}, {});

// Then we will apply the colors to the DOM :root element
this.createCustomProperties(colors, 'p');
}

Then we will create the custom properties

createCustomProperties(
colorsFromPaletteConfig: colorsFromPaletteConfig,
paletteKey: 'p' | 't',
) {

let styleString = ':root,:host{';

for (const [key, palette] of Object.entries(colorsFromPaletteConfig)) {
palette.forEach(({ hex, tone }) => {

if (key === 'primary') {
styleString += `--${key}-${tone}:${hex};`;
} else {
styleString += `--${paletteKey}-${key}-${tone}:${hex};`;
}
});
}

styleString += '}';

this.applyThemeString(styleString, 'angular-material-theme');
}

The last part we need to do is attach our custom properties to the DOM

applyThemeString(
themeString: string,
ssName = 'angular-material-theme')
{
let sheet = (globalThis as WithStylesheet)[ssName];

if (!sheet) {
sheet = new CSSStyleSheet();
(globalThis as WithStylesheet)[ssName] = sheet;
this.#document.adoptedStyleSheets.push(sheet);
}
sheet.replaceSync(themeString);
}

And the is the final result is the example gif from the begining.

--

--