Mert Özen Further With Every Line
Frontend Sass/SCSS — #05

Theming with Sass: Maps, Generating CSS Custom Properties, Dark Mode and an Angular Material M3 Theme

Mert Özen Nov 14, 2026 11 min 11 views
Theming with Sass: Maps, Generating CSS Custom Properties, Dark Mode and an Angular Material M3 Theme

Keep theme values in a Sass map, validate them and turn them into custom properties with light-dark(); switching themes at runtime means changing only color-scheme or a class on html, because Sass variables never change at runtime. We add a mixin that fails the build on a missing token, a three-state theme button and an Angular Material M3 theme generated from the brand color to Bookstore, measure eight states across two themes, and show why one bundle per theme is not worth it.

Version note: The examples in this post were run with Dart Sass 1.108.2, Angular 22.2 (TypeScript 6.0; the Sass 1.104.1 bundled with the Angular CLI), Angular Material 22.2.7 and the Chromium build of Playwright 1.65.

Guess First

Bookstore, the online bookstore used as the shared example across this site's series, is getting a dark mode. A teammate new to Sass tried the most natural-looking approach: to change the brand color in the dark theme, they gave the variable a new value inside the .dark class.

$brand: #b45309;

.dark {
    // a new local variable, not the global one
    $brand: #f59e0b;
}

.book-card__title {
    color: $brand;
}

What color is the book title when the page has <html class="dark">? Is there a .dark rule in the compiled CSS at all?

npx sass 05/runtime-myth.scss
.book-card__title {
  color: #b45309;
}

The title is always brown and .dark does not appear in the output at all. The $brand inside the .dark block is a new variable that lives only inside that block; since the block produces no declarations, Sass drops it. The deeper reason is familiar from the first post: once compilation finishes no variables are left, and the browser has no way to ask Sass which class is active. Everything that changes at runtime has to be a custom property.

In this post we split the work in two: holding the theme values, validating them and turning them into custom properties is Sass's job; deciding which theme is active is CSS's. Then we combine the same structure with Angular Material's M3 theme in Bookstore's Angular client (bookstore-web).

Generating Custom Properties from a Token Map

I keep each theme's values in a map. The two maps must have the same keys; we will have the compiler check that shortly.

$themes: (
    light: (
        surface: #ffffff,
        text: #1c1917,
        muted: #57534e,
        border: #d6d3d1,
        brand: #b45309,
    ),
    dark: (
        surface: #1c1917,
        text: #f5f5f4,
        muted: #a8a29e,
        border: #44403c,
        brand: #f59e0b,
    ),
);

There are two ways to generate CSS from this map. The first is the approach used for years: a separate block per theme.

@use "sass:map";
@use "themes" as *;

@mixin theme-vars($theme) {
    @each $name, $value in $theme {
        --color-#{$name}: #{$value};
    }
}

:root,
.light {
    @include theme-vars(map.get($themes, light));
}

// the OS asks for dark and the user has not pinned light
@media (prefers-color-scheme: dark) {
    :root:not(.light) {
        @include theme-vars(map.get($themes, dark));
    }
}

.dark {
    @include theme-vars(map.get($themes, dark));
}
:root,
.light {
  --color-surface: #ffffff;
  --color-text: #1c1917;
  --color-muted: #57534e;
  --color-border: #d6d3d1;
  --color-brand: #b45309;
}

@media (prefers-color-scheme: dark) {
  :root:not(.light) {
    --color-surface: #1c1917;
    --color-text: #f5f5f4;
    --color-muted: #a8a29e;
    --color-border: #44403c;
    --color-brand: #f59e0b;
  }
}
.dark {
  --color-surface: #1c1917;
  --color-text: #f5f5f4;
  --color-muted: #a8a29e;
  --color-border: #44403c;
  --color-brand: #f59e0b;
}

It works, but the dark values are written twice: once for the OS preference and once for the user's choice. The second way uses CSS's light-dark() function. It takes two values and picks one based on the element's color-scheme; to switch themes, changing color-scheme is enough. The details are in the custom properties and theming post of the CSS3 series. Sass's job is to merge the two maps and produce a single declaration per token:

@use "sass:map";
@use "themes" as *;

@mixin color-tokens($themes) {
    $light: map.get($themes, light);
    $dark: map.get($themes, dark);

    @each $name, $value in $light {
        @if not map.has-key($dark, $name) {
            @error "Dark theme is missing '#{$name}'";
        }
        --color-#{$name}: light-dark(#{$value}, #{map.get($dark, $name)});
    }
}

:root {
    color-scheme: light dark;
    @include color-tokens($themes);
}

.light {
    color-scheme: light;
}

.dark {
    color-scheme: dark;
}
:root {
  color-scheme: light dark;
  --color-surface: light-dark(#ffffff, #1c1917);
  --color-text: light-dark(#1c1917, #f5f5f4);
  --color-muted: light-dark(#57534e, #a8a29e);
  --color-border: light-dark(#d6d3d1, #44403c);
  --color-brand: light-dark(#b45309, #f59e0b);
}

.light {
  color-scheme: light;
}

.dark {
  color-scheme: dark;
}

Compressed, the first approach is 425 bytes and the second 303. The difference is small for five tokens but grows in direct proportion to the token count. More importantly, it reads better: each token is one line with its light and dark values side by side. And color-scheme itself also tells the browser to match form controls, scrollbars and the default background to the theme; with the block approach you had to write that separately.

The @error line is the real value of this mixin. If someone adds a new token to the light theme and forgets the dark one, the build breaks. I removed brand from the dark theme to try it:

Error: "Dark theme is missing 'brand'"
   ╷
18 │     @include color-tokens($themes);
   │     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
   ╵
  05/tokens-light-dark.scss 18:5  root stylesheet

Working with custom properties alone, that missing token would be an empty value in the browser and a heading would silently keep its light-theme color in dark mode. light-dark() has been Baseline since May 2024; if you need to support older browsers, going back to the block approach is as easy as changing the mixin.

In Bookstore: Theme Tokens in abstracts

In bookstore-web, the $themes map went into src/styles/abstracts/_tokens.scss and the color-tokens() mixin into _theme.scss. The mixin follows the previous post's rule: abstracts still produce no CSS; the file that calls the mixin produces it. Bookstore's mixin goes one step further than the one above and checks the reverse direction too: a key that exists in dark but not in light is also an error.

@use "sass:map";

// --color-* custom properties; light-dark() picks the value at runtime from color-scheme
@mixin color-tokens($themes) {
    $light: map.get($themes, light);
    $dark: map.get($themes, dark);

    @each $name in map.keys($dark) {
        @if not map.has-key($light, $name) {
            @error "Light theme is missing '#{$name}'";
        }
    }

    @each $name, $value in $light {
        @if not map.has-key($dark, $name) {
            @error "Dark theme is missing '#{$name}'";
        }
        --color-#{$name}: light-dark(#{$value}, #{map.get($dark, $name)});
    }
}

Nothing changed in the components' stylesheets. BookCard already used var(--color-brand) and var(--color-border); this is where the "components use only custom properties" rule from the second post pays off.

Switching Themes at Runtime

The user's choice is a single class added to <html>. I put a three-state button in the header: system, light, dark.

import { Component, DOCUMENT, effect, inject, signal } from '@angular/core';
import { MatButton } from '@angular/material/button';

type Theme = 'system' | 'light' | 'dark';
const NEXT: Record<Theme, Theme> = { system: 'light', light: 'dark', dark: 'system' };

@Component({
  selector: 'app-theme-toggle',
  imports: [MatButton],
  template: `<button matButton="outlined" (click)="toggle()">Theme: {{ theme() }}</button>`,
})
export class ThemeToggle {
  private readonly root = inject(DOCUMENT).documentElement;
  protected readonly theme = signal<Theme>('system');

  constructor() {
    // only a class changes at runtime; every color is already in the CSS
    effect(() => {
      this.root.classList.remove('light', 'dark');
      const theme = this.theme();
      if (theme !== 'system') {
        this.root.classList.add(theme);
      }
    });
  }

  protected toggle(): void {
    this.theme.update((current) => NEXT[current]);
  }
}

There is no color in this component, and no Sass variable either. JavaScript's only job is to change the class. The Playwright script opens the page first with the OS in light mode, then in dark mode, clicks the button three times in each case and reads the body background, the book title color and the Material button's background:

node scripts/05-theme.mjs
OS light               class=-     body=rgb(255, 255, 255) title=rgb(180, 83, 9)    button=rgb(155, 69, 0)
OS light, theme light  class=light body=rgb(255, 255, 255) title=rgb(180, 83, 9)    button=rgb(155, 69, 0)
OS light, theme dark   class=dark  body=rgb(28, 25, 23)    title=rgb(245, 158, 11)  button=rgb(255, 182, 142)
OS light, theme system class=-     body=rgb(255, 255, 255) title=rgb(180, 83, 9)    button=rgb(155, 69, 0)
OS dark                class=-     body=rgb(28, 25, 23)    title=rgb(245, 158, 11)  button=rgb(255, 182, 142)
OS dark, theme light   class=light body=rgb(255, 255, 255) title=rgb(180, 83, 9)    button=rgb(155, 69, 0)
OS dark, theme dark    class=dark  body=rgb(28, 25, 23)    title=rgb(245, 158, 11)  button=rgb(255, 182, 142)
OS dark, theme system  class=-     body=rgb(28, 25, 23)    title=rgb(245, 158, 11)  button=rgb(255, 182, 142)

All eight lines are consistent: with no class the OS preference applies; with a class, the user's choice. Our tokens (the title) and Angular Material's tokens (the button) change together, because both look at the same color-scheme. Let us see how that works.

An Angular Material M3 Theme

I installed Angular Material and generated a palette from the brand color. The theme-color schematic computes M3's tonal palettes (shades of the same color from 0 to 100 lightness) from the given color and writes them to a Sass file:

npm install --save-exact @angular/material@22.2.7 @angular/cdk@22.2.7
npx ng generate @angular/material:theme-color --primary-color="#b45309" --tertiary-color="#1d4ed8" --directory=src/styles/themes/
CREATE src/styles/themes/_theme-colors.scss (2535 bytes)

The beginning and end of the generated file:

// This file was generated by running 'ng generate @angular/material:theme-color'.
// Proceed with caution if making changes to this file.

@use 'sass:map';
@use '@angular/material' as mat;

// Note: Color palettes are generated from primary: #b45309, tertiary: #1d4ed8
$_palettes: (
    primary: (
        0: #000000,
        10: #331200,
        20: #532200,
        ...
        40: #9b4500,
        ...
        80: #ffb68e,
        ...
    ),
    ...
);
...
$primary-palette: map.merge(map.get($_palettes, primary), $_rest);
$tertiary-palette: map.merge(map.get($_palettes, tertiary), $_rest);

Because $_palettes starts with an underscore it is private; the file exposes only $primary-palette and $tertiary-palette. The module system rules from the previous post apply to generated code too. The final global styles.scss:

@use "@angular/material" as mat;
@use "abstracts" as *;
@use "themes/theme-colors" as brand;
@use "utilities";

html {
    // follows the OS by default; .light / .dark pin it
    color-scheme: light dark;

    @include mat.theme((
        color: (
            primary: brand.$primary-palette,
            tertiary: brand.$tertiary-palette,
        ),
        typography: system-ui,
        density: 0,
    ));
    @include color-tokens($themes);

    // Material buttons use Bookstore's corner radius instead of the pill shape
    @include mat.button-overrides((
        filled-container-shape: $radius,
        outlined-container-shape: $radius,
    ));

    --radius: #{$radius};
    --space: #{$space};

    &.light {
        color-scheme: light;
    }

    &.dark {
        color-scheme: dark;
    }
}

body {
    margin: 0;
    font-family: system-ui, sans-serif;
    background-color: var(--color-surface);
    color: var(--color-text);
}

mat.theme() is a mixin; it writes 157 --mat-sys-* custom properties into the selector it is called in, and by default generates the colors with light-dark(). One line from the compiled CSS:

--mat-sys-primary: light-dark(#9b4500, #ffb68e);

That is why the Material button measured rgb(155, 69, 0) in the light theme and rgb(255, 182, 142) in the dark one above: the primary color is tone 40 of the palette in light mode and tone 80 in dark mode. Note that neither is the #b45309 we passed in. M3 uses the source color as a starting point and picks tones from the palette that follow its contrast rules. The exact brand value lives in Bookstore's own tokens (--color-brand), while Material components use their own tones. How the two look side by side is something to review with the designer.

mat.button-overrides() is Material's token-based customization API. Each component has its own *-overrides mixin, and it validates the token names at build time; a misspelled name like filled-container-shap breaks the build with an Invalid token name error and the list of valid tokens. The buttons now use Bookstore's 8px corner radius:

node scripts/web-probe.mjs ".book-card__add" border-top-left-radius
Add to cart              border-top-left-radius=8px

The Angular Material documentation treats components' internal classes and DOM structure as private and explicitly discourages overriding them with CSS; these overrides mixins are the supported route. If you want Material's colors in your own components, you can use system tokens such as var(--mat-sys-primary) directly; they look at the same color-scheme too.

In Production

Persist the user's choice and apply it before Angular starts. The ThemeToggle in this post keeps the choice in memory; reloading the page goes back to the system theme. In a real application you write the choice to localStorage and put the class on <html> with a small inline script in index.html before Angular loads; otherwise a user who chose dark sees the light theme for a moment on every visit. The details of this FOUC problem are in the theming post of the CSS3 series.

Measure the bundle. Adding Angular Material took Bookstore's initial JavaScript bundle from 127 kB to 194 kB, and the global CSS from 1 kB to 9 kB, most of it the 157 system tokens. That is not a warning but a deliberate decision: every Material component you use adds a cost to the bundle.

Check the contrast of the generated tones. The schematic picks tones according to M3's rules, but Bookstore's own tokens (such as --color-brand) do not go through those rules. Measuring the text and background pairs in both themes with the tools in the Accessibility (a11y) series should be the last step before release.

Common Pitfalls

Producing a separate CSS bundle per theme. Generating two stylesheets with Material's theme-type: light and theme-type: dark and swapping the <link> at runtime is an old approach. I compiled the same theme three ways and compared the sizes:

node scripts/05-material-sizes.mjs
material-one     8038 bytes, gzip 1476 bytes, 157 --mat-sys-* properties
material-light   6985 bytes, gzip 1285 bytes, 157 --mat-sys-* properties
material-dark    6985 bytes, gzip 1297 bytes, 157 --mat-sys-* properties

The single-file light-dark() version is about 190 bytes larger than one theme after gzip. To save those 190 bytes you have to produce two files, download a new file when the theme changes, and risk an unstyled page while it downloads. As the number of themes grows (high contrast, a theme for institutional customers), the build output multiplies too. One file and color-scheme is far simpler.

Trying to change a Sass variable at runtime. The example at the start of this post is the most common form of this. Another is trying to change a Sass variable from JavaScript: $brand does not exist in the compiled CSS, so there is nothing to change. The rule is simple: Sass produces the values, custom properties carry them, a class or color-scheme selects them.

Overriding Material's internal classes. Rules written against classes like .mdc-button__label can silently stop working with the next Material release. The overrides mixins, on the other hand, give a build error if a token name changes; you see the problem at build time, not in production.

Giving the theme-color schematic a folder without a trailing /. When I wrote --directory=src/styles/themes, the file was created as src/styles/themes_theme-colors.scss; the schematic concatenates the folder name and the file name directly. With a trailing / it wrote to the right place.

A Small Experiment

★☆☆ Add accent: #38bdf8 to the dark theme only in 05/_themes.scss and compile tokens-light-dark.scss. Does the lab's mixin catch it? Compare with the mixin in bookstore-web.

★★☆ Write ThemeToggle's choice to localStorage and add an inline script to index.html that sets the class before Angular loads. With Playwright, choose the dark theme, reload the page and verify that the body background is dark on first paint.

★★★ Add a third theme to $themes: high contrast. color-tokens() can no longer get by with light-dark(); extend the mixin to generate the high-contrast values under @media (prefers-contrast: more) and a .high-contrast class, try the --include-high-contrast option of the theme-color schematic for Material, and measure how the two work together.

In the next post we move from individual files to the style tree as a whole: the 7-1 folder structure, BEM and ITCSS's idea of layers, how those layers become real in the browser with @layer, and where the line between global, component and utility styles runs in Bookstore.