Sass and custom properties for themes
Sass variables disappear after compiling, so they cannot switch a theme while the page is open. CSS custom properties can — but writing dozens of them by hand for every theme is tedious. The best of both: keep the themes in Sass maps and let a loop turn them into custom properties.
Themes in a map
Each theme is a map of token names to colours. Create styles/themes/_themes.scss:
// styles/themes/_themes.scss
$themes: (
"light": (
"bg": #ffffff,
"text": #1f2937,
"accent": #2563eb,
),
"dark": (
"bg": #111827,
"text": #f9fafb,
"accent": #60a5fa,
),
);Generating custom properties
A mixin takes one theme and prints a custom property for every token. The --#{$name} interpolation builds the property name:
// styles/themes/_index.scss
@use "sass:map";
@use "themes";
@mixin theme-vars($theme) {
@each $name, $value in $theme {
--#{$name}: #{$value};
}
}
:root {
@include theme-vars(map.get(themes.$themes, "light"));
}
[data-theme="dark"] {
@include theme-vars(map.get(themes.$themes, "dark"));
}The light theme goes to :root — it is the default. The dark one applies when an element has the attribute data-theme="dark".
Components use var()
Components no longer know the colours: they read custom properties. These values are resolved in the browser, so they follow the current theme:
// styles/components/_cards.scss
.card {
padding: 16px;
color: var(--text);
background: var(--bg);
border: 2px solid var(--accent);
}// styles/main.scss
@use "themes";
@use "components/cards";:root {
--bg: #ffffff;
--text: #1f2937;
--accent: #2563eb;
}
[data-theme=dark] {
--bg: #111827;
--text: #f9fafb;
--accent: #60a5fa;
}
.card {
padding: 16px;
color: var(--text);
background: var(--bg);
border: 2px solid var(--accent);
}Sass removed the quotes in [data-theme=dark] — for the browser it is the same selector.
Switching the theme
One attribute on <html> switches every colour. The script only sets the attribute; all colours are in the CSS:
<style>
:root {
--bg: #ffffff;
--text: #1f2937;
--accent: #2563eb;
}
[data-theme=dark] {
--bg: #111827;
--text: #f9fafb;
--accent: #60a5fa;
}
.card {
padding: 16px;
color: var(--text);
background: var(--bg);
border: 2px solid var(--accent);
}
</style>
<div class="card">
<p>The card follows the theme.</p>
<button type="button" id="toggle">Switch theme</button>
</div>
<script>
document.querySelector('#toggle').addEventListener('click', () => {
const root = document.documentElement;
root.dataset.theme = root.dataset.theme === 'dark' ? 'light' : 'dark';
});
</script>Click the button: the card's background, text and border change at once, without recompiling.
Who does what
| Task | Tool |
|---|---|
| Store themes and tokens, check names | Sass maps and functions |
| Generate the variables for every theme | Sass @each |
| Switch a theme on the page | CSS custom properties |
| Read a colour in a component | var(--accent) |
Sass colour functions still help here: for example, a hover shade can be computed with color.adjust() for each theme while generating the map.
Common mistake
Sass functions cannot work with var(). color.adjust(var(--accent), $lightness: -10%) stops with an error: at compile time var(--accent) is just text, its colour is known only in the browser. Calculate shades in Sass before they become custom properties, or use CSS functions like color-mix() that work in the browser.