Introduction

CSS variables (aka CSS custom properties) are particularly useful when it comes to theming a web application. When using SCSS, it can be difficult to see how SCSS and CSS variables can be used in conjunction with one another. This usually results in a disconnect between the two: SCSS variables for shared properties, with separate CSS variables for theme properties - both working alongside one another, but without any cooperation

01-scss--theme-change

CSS Variables in SCSS

Using CSS variables independently of SCSS specific syntax has a few disadvantages:

  • misspelled CSS variables are only caught by linters, or with manual testing
  • no autocompletion or IntelliSense
  • renaming variables requires a search-and-replace across the project

Variable declarations

Lets put together a basic CSS theme, with light and dark classes. The first step is to declare our CSS variables, but we will do so by assigning them to corresponding SCSS variables:

_variables.scss

$--theme-primary: --theme-primary;
$--theme-secondary: --theme-secondary;
$--theme-surface: --theme-surface;
$--theme-background: --theme-background;
$--theme-on-primary: --theme-on-primary;
$--theme-on-secondary: --theme-on-secondary;
$--theme-on-surface: --theme-on-surface;

This is the key step in the process. We get all the advantages of SCSS variables during development, and the flexibility of CSS variables at runtime

Creating the Themes

In the previous step we assigned our CSS variable names to corresponding SCSS variables. This comes in handy for creating the themes. A basic approach to creating the themes would be to use the SCSS variables directly, and assign a value to each (we won't do this btw):

themes.scss

@import 'variables';
@import 'mixins';

// Don't do this
:root.light {
    #{$--theme-primary}: #d75893,
    #{$--theme-secondary}: #9b4dca,
    #{$--theme-surface}: #fff,
    #{$--theme-background}: #fafafa,
    #{$--theme-on-primary}: #fff,
    #{$--theme-on-secondary}: #fff,
    #{$--theme-on-surface}: #000,
}

Theme variable maps

There is a cleaner approach, that allows us to keep the theme variables in the _variables.scss file, and remove the need to use the #{} syntax. We will add the theme variables to maps, and include these maps in CSS classes when we need them

_variables.scss

// Default theme
$theme-map-light: (
    $--theme-primary: #d75893,
    $--theme-secondary: #9b4dca,
    $--theme-surface: #fff,
    $--theme-background: #fafafa,
    $--theme-on-primary: #fff,
    $--theme-on-secondary: #fff,
    $--theme-on-surface: #000,
);

// Override the default light theme
$theme-map-dark: (
    $--theme-primary: #f34c84,
    $--theme-secondary: #c297dc,
    $--theme-surface: #2f2b2b,
    $--theme-background: #1b1919,
    $--theme-on-primary: #443e42,
    $--theme-on-secondary: #000,
    $--theme-on-surface: #f5f1f1,
);

Using a simple mixin, we can add these maps to theme classes in a clear and concise manner:

_mixins.scss

@mixin spread-map($map: ()) {
    @each $key, $value in $map {
        #{$key}: $value;
    }
}

themes.scss

@import 'variables';
@import 'mixins';

:root.light {
    @include spread-map($theme-map-light);
}
:root.dark {
    @include spread-map($theme-map-dark);
}

That's our themes done👍 Neat and tidy!

The CSS after compilation will look this this:

themes.css output

:root.light {
  --theme-primary: #d75893;
  --theme-secondary: #9b4dca;
  --theme-surface: #fff;
  --theme-background: #fafafa;
  --theme-on-primary: #fff;
  --theme-on-secondary: #fff;
  --theme-on-surface: #000;
}
:root.dark {
  --theme-primary: #f34c84;
  --theme-secondary: #c297dc;
  --theme-surface: #2f2b2b;
  --theme-background: #1b1919;
  --theme-on-primary: #443e42;
  --theme-on-secondary: #000;
  --theme-on-surface: #f5f1f1;
}

Using the CSS variables

To apply a custom CSS variable, we can use the standard CSS var() syntax:

fa-icon {
    color: var($--theme-on-primary);
}

This would work, but seeing as we have a map of theme variables, we can add in some error checking with a function:

_mixins.scss

@function theme-var($key, $fallback: null, $map: $theme-map-light) {
    @if not map-has-key($map, $key) {
        @error "key: '#{$key}', is not a key in map: #{$map}";
    }
    @if ($fallback) {
        @return var($key, $fallback);
    } @else {
        @return var($key);
    }
}

If you try to use a variable that is not part of the default theme ($theme-map-light in this example) it will throw an error

To use the function, simply pass in the SCSS variable, along with a fallback value if you require one:

SCSS

fa-icon {
    color: theme-var($--theme-on-primary);
}

.theme-switcher {
    position: absolute;
    background-color: theme-var($--theme-secondary);
    color: theme-var($--theme-on-secondary);
}

This compiles to the following CSS:

CSS

fa-icon {
    color: var(--theme-on-primary);
}

.theme-switcher {
    position: absolute;
    background-color: var(--theme-secondary);
    color: var(--theme-on-secondary);
}

IntelliSense in VSCode

Using the SCSS IntelliSense extension in VSCode, we get auto-completion for the variables, making the developer experience that much better! This is something we would miss out on with pure CSS

01-scss--intellisense-vscode

Angular Implementation

Firstly lets look at the folder structure

01-scss--folder-structure

Style files setup

You'll notice that the styles.scss file has been moved into a styles folder. This is optional, but it makes sense to have all the styles files in the same folder. You'll need to add stylePreprocessorOptions in angular.json to both the build and test options

The styles property in options needs to be updated too, to reflect the new location of the styles.scss file

angular.json

"architect": {
    "build": {
        "builder": "@angular-devkit/build-angular:browser",
        "options": {
            "outputPath": "dist/demo",
            "index": "src/index.html",
            "main": "src/main.ts",
            "polyfills": "src/polyfills.ts",
            "tsConfig": "tsconfig.app.json",
            "aot": false,
            "assets": ["src/favicon.ico", "src/assets"],
            "stylePreprocessorOptions": {
                "includePaths": ["src/styles"]
            },
            "styles": [
                {
                    "input": "src/styles/themes.scss",
                    "bundleName": "themes"
                },
                "src/styles/styles.scss"
            ],
            "extractCss": true,
            "scripts": []
        },

Themes bundle

As we have a separate themes.scss file, the architect.build.options.styles needs to inlcude "src/styles/themes.scss" for them to be included in the build. The theme's css would then be bundled with the styles.css during compilation

There is another way we can include the themes though, and that's as a separate bundle. To do this we this we use the input and bundleName options, and add "extractCss": true

"styles": [
    {
        "input": "src/styles/themes.scss",
        "bundleName": "themes"
    },
    "src/styles/styles.scss"
],
"extractCss": true,

extractCSS: Extract css from global styles into css files instead of js ones - Angular CLI build docs

The advantage of the bundleName approach is mainly for debugging and development purposes - in DevTools you will have a dedicated themes.css file in the Sources panel, meaning that you can easily find and edit your styles when creating your themes without having to look through the minified styles.css bundle

01-scss--extractcss

For preformance reasons you can override the separate themes bundle creation in configurations.production.styles by removing input/bundleName and replacing with "src/styles/themes.scss". This way the separate bundles will only apply during development

Theme Switching

A common approach to switching themes at runtime is to add theme classes to the html or body element. I don't have a strongly held opinion on which to use, but I'm leaning towards the html element - it means we can use :root in the CSS exclusively for custom variables, which keeps things simple

If we have a default :root.light and additional :root.dark theme in our CSS, then switching themes involves adding and removing these classes on the html element.

To make the change in themes less jarring for the end-user, we can also add a temporary class (e.g theme-transiton) to the html element when the theme is changed, and remove it after a period of time e.g. 1000ms. In the CSS we would add a selector to apply a transition effect to all elements while the theme-transition is in play:

themes.scss

:root.theme-transition,
:root.theme-transition * {
    transition: background-color 1000ms !important;
    transition-delay: 0s !important;
}

Theme switching: ngx-theme-service

The last step in the process is actually switching between the themes. We need to remove any theme currently applied, and add the new one along with a temporary transition class (if you want to see a transition effect)

If you use an explicit default theme e.g. light, then this would remain on the html element when the selected theme is applied

The theme.service.ts code below is also available as an npm library, and you can see an example usage and more details on the GitHub page

theme.service.ts

import { DOCUMENT } from '@angular/common';
import { Inject, Injectable, InjectionToken, OnDestroy } from '@angular/core';
import { BehaviorSubject, Observable, Subject, timer } from 'rxjs';
import { switchMap, takeUntil, tap } from 'rxjs/operators';

/** Apply a CSS class to the `<html>` element when switching themes */
export interface ThemeTransitionConfig {
    readonly className: string;
    /** remove class after duration in milliseconds */
    readonly duration: number;
}

export interface ThemeServiceConfig {
    readonly themes: ReadonlyArray<string>;
    /** theme that should always be on the target element if using explicit default theme */
    readonly defaultTheme?: string;
    /** optional transition configuration */
    readonly transitionConfig?: ThemeTransitionConfig;
    /** themes applied to <html> by default. Supply CSS selector to change */
    readonly targetElementSelector?: string;
}

export const THEME_CONFIG = new InjectionToken<ThemeServiceConfig>(
    'ThemeService: Config'
);

// https://angular.io/guide/angular-compiler-options#strictmetadataemit
// @dynamic
@Injectable({
    providedIn: 'root',
})
export class ThemeService implements OnDestroy {
    private stopListening$ = new Subject<boolean>();
    private selectedTheme: BehaviorSubject<string> = new BehaviorSubject(
        this.config.defaultTheme || ''
    );
    selectedTheme$: Observable<string> = this.selectedTheme.asObservable();

    constructor(
        @Inject(THEME_CONFIG) private config: ThemeServiceConfig,
        @Inject(DOCUMENT) private document: Document
    ) {
        this.setupSubscription();
    }

    switchTheme(className: string) {
        this.selectedTheme.next(className);
    }

    private setupSubscription() {
        const transitionConfig = this.config.transitionConfig;
        const nonDefaultThemes = this.config.themes.filter(
            c => c !== this.config.defaultTheme
        );

        this.selectedTheme
            .pipe(
                tap(theme => {
                    this.removeClasses(nonDefaultThemes);
                    // Conditional literal entries:
                    // https://2ality.com/2017/04/conditional-literal-entries.html
                    const toAdd = [
                        ...(theme ? [theme] : []),
                        ...(transitionConfig
                            ? [transitionConfig.className]
                            : []),
                    ];
                    this.addClasses(toAdd);
                }),
                transitionConfig
                    ? switchMap(value => {
                          return timer(transitionConfig.duration).pipe(
                              tap(x => {
                                  this.removeClasses([
                                      transitionConfig.className,
                                  ]);
                              })
                          );
                      })
                    : tap((x: any) => {}),
                takeUntil(this.stopListening$)
            )
            .subscribe();
    }

    private removeClasses(arr: string[]) {
        this.targetElement.classList.remove(...arr);
    }

    private addClasses(arr: string[]) {
        this.targetElement.classList.add(...arr);
    }

    private get targetElement(): HTMLElement {
        let elem: HTMLElement;
        if (this.config.targetElementSelector) {
            elem = this.document.querySelector(
                this.config.targetElementSelector
            );
            if (!elem) {
                console.warn(
                    `${this.config.targetElementSelector} not found, defaulting to <html>`
                );
            }
        }
        if (!elem) {
            elem = this.document.documentElement;
        }
        return elem;
    }

    ngOnDestroy(): void {
        this.stopListening$.next(true);
    }
}

Demo Application

The demo application for ngx-theme-service uses the SCSS and CSS custom variables approach described in this article - it's a great way to see it in action. The code is available on GitHub, and the the service can be installed via npm

Further Reading

Brian Bishop

Dublin, Ireland
v7.2.15+sha.e7897e1