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
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
Angular Implementation
Firstly lets look at the 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
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