Dynamic theming a scss library
My library hop's styling is based on SCSS, which lets me define default variables like $primary-color: #83ba43 !default;
in the library, and then overwrite it in other projects that use the library to have a different primary color.
You could consider that "theming" but my goal is to have a light/dark mode, or even more than two themes, and allow users to switch between them.
The lazy approach
In theory, any SCSS library that works with variables already supports what I want. I can simply use Vite/Webpack to compile the SCSS of the app once for every theme with a different set of variables.
I think this would require creating an entrypoint SCSS file for every theme or configuring a webpack entrypoint for every theme with the usage of sass-loaders additionalData
Cons
- Switching the theme requires loading a whole new css file, this can be done with some js, or using backend logic (a cookie, reloading the page with the corresponding
<link rel=..
tag. - The bundler has to build a whole css file for each theme which can be slow
- Switching out the css file or reloading the page is not smooth
The lazy but better approach
So what about just having one css file then? Most scss theme solutions take the following approach:
$themes: (
light: (
text: #333333,
bg: #ffffff,
),
dark: (
text: #ffffff,
bg: #333333,
),
);
@mixin themed($themes) {
@each $name, $values in $themes {
.#{$name}-theme {
$theme-map: $values !global;
@content;
}
}
}
@function getThemeColor($key) {
@return map-get($theme-map, $key);
}
@include themed($themes) {
p {
color: getThemeColor("text");
background-color: getThemeColor("bg");
}
}
This is almost perfect, you only have to write the scss for something once and scss automatically generates css for every theme. The user can switch between themes smoothly when the .<name>-theme
class is attached to the body tag.
Con
Every declaration inside the themed
mixin will be put into the result CSS once per theme. For the properties like color or background-color, that is exactly what I want, but for every other property, I end up with a lot of duplications that bloat the stylesheet.
This can be reduced by splitting it
p {
display: flex;
flex-direction: row;
justify-content: center;
}
@include themed($themes) {
p {
color: getThemeColor("text");
background-color: getThemeColor("bg");
}
}
But that means I have to be careful when writing new scss in the library because there is no obvious mechanism that prevents me from accidentaly causing duplicate declarations.
CSS-Variables
CSS offers variables (also called "custom properties") that enable simple theming:
:root {
--text-color: #333333;
}
.dark-theme {
--text-color: #ffffff;
}
p {
color: var(--text-color);
}
Relative Colors
One thing that bothers me with this approach is that you are forced to mirror almost all variables in every theme.
Let me explain: In my library, a lot of SCSS variables and colors/backgrounds are derived from existing SCSS variables, for example, there is one variable that defines the base background color, and the whole library uses this variable to define different shades of gray (pun intended) for the background color of things like modals, cards, and input fields.
When using CSS variables, this would mean I have to define one for every variation of the background color - If I create a new theme, I have to go through all variables of the base theme and redefine them according to the new theme.
Luckily, CSS offers relative colors too, but the syntax is so unbelievably ugly that I would rather define 200 variables than write something like this:
/* darken a color */
background-color: hsl(from var(--base) h s calc(l - 20%));
/* transparentize a color */
background: rgba(from var(--base) r g b / 0.3);
The scss syntax is way more readable and is more flexible.
//darken a color
background-color: color.adjust($base, $lightness: -20%);
//transparentize a color
background-color: rgba($base, 0.3);
Combining CSS Variables and SCSS Variables
So here is what I came up with:
There is a set of basic scss variables for things like background/text color, primary color and so on
$primary-color: #83ba43 !default;
$bg-color: hsl(191 28% 10% / 1) !default;
$fg-color: #bbbbbb !default;
The scss variables are used to define the default theme
$themes: registerTheme(
"default",
(
"primary": $primary-color,
"bg": $bg-color,
"fg": $fg-color,
)
);
The other themes overwrite some colors of the default theme
$themes: registerTheme(
"bright",
(
"bg": hsl(0, 0%, 95%),
"fg": #3d3d3d
)
);
Each part of my library then takes the theme colors to define css variables.
As you can see I do not need to redefine the primary color in the bright theme. But an alert that uses a combination of bg
and primary
will still be recomputed and I can use my utility function to automatically compute a WCAG AA compliant foreground color:
//_alerts.scss
$alert-variants: "primary", "secondary", "error", "success", "warning";
@include theme.themed() using ($theme) {
@each $variant in $alert-variants {
$color: theme.color($theme, $variant);
$foreground: rgba(color.adjust($color, $lightness: 10%), 15%);
$result: utility.alpha-blend($foreground, theme.color($theme, "bg"));
--h-alert-#{$variant}-bg: #{$result};
--h-alert-#{$variant}-fg: #{utility.getContrastColor($result)};
--h-alert-#{$variant}-border: #{rgba($color, 0.3)};
}
}
The themed
mixin works like this:
@mixin themed() {
@each $theme_name, $theme in $themes {
$selector: if($theme_name == "default", ":root", "[data-theme='#{$theme_name}']");
#{$selector} {
@content ($theme);
}
}
}
It outputs a :root
part for the default theme and a [data-theme=<name>]
part for all other themes containing the css variables of the respective theme.
Benefits of this approach
Any theme color that hasn't been overwritten is still based on the initial SCSS variables. So, if I use the library in a different project, I can change the primary color, which will automatically affect all themes and colors based on that primary color!
Let's say I want to define a custom theme in a project that uses the library and I just want to make all backgrounds darker:
@use "pkg:@codingkiwi/hop/scss/hop.scss" with (
$themes: (
"custom": (
"bg": black
)
)
);
Thats it, just one color, all other backgrounds like cards / modals / input fields are darker!
I do not need to know all the css variables that use the "bg" color.
I do not need to worry about future library updates that introduce or remove css variables that rely on the "bg" color because they are generated automatically

Optimizing duplicate blocks
Since the theme is made up of many independent files (to allow using only the parts of the library you need) this means for every usage of the themed
mixin times the amount of themes one block of variables is generated
/* base variables */
:root {--example: black}
[data-theme=bright] {--example: white}
[data-theme=pink] {--example: pink}
/* from _alerts.scss */
:root {--alert-background: black}
[data-theme=bright] {--alert-background: white}
[data-theme=pink] {--alert-background: pink}
...
I solved this by letting each module define its own mixin and globally registering the mixin:
$theme-mixins: () !default;
@mixin registerThemeMixin($mixin) {
$theme-mixins: list.append($theme-mixins, $mixin) !global;
}
@mixin generateThemes($whitelist: ()) {
@each $theme_name, $theme in $themes {
@if list.length($whitelist) == 0 or list.index($whitelist, $theme_name) {
$selector: if($theme_name == "default", ":root", "[data-theme='#{$theme_name}']");
#{$selector} {
@each $mixin in $theme-mixins {
@include meta.apply($mixin, $theme);
}
}
}
}
}
//_alerts.scss
@mixin alerts-theme-mixin($theme) {
...
}
@include theme.registerThemeMixin(meta.get-mixin("alerts-theme-mixin"));
This way only one block is generated per theme, as long as the generateThemes
mixin is included at the very end after all library modules have been included
Optimizing duplicate variables
The next "problem" is that eventhough many variables do not differ between themes (the primary color for example) they are still overwritten and redefined in each theme
/* base variables */
:root {
--bg: black;
--primary: green;
...
}
[data-theme=bright] {
--bg: white;
--primary: green;
...
}
[data-theme=pink] {
--bg: pink;
--primary: green;
...
}
So lets get even more complicated:
@function addVariables($theme, $vars) {
$theme-name: map.get($theme, "_name");
$current-vars: map.get($theme, "_vars");
$theme: map.set($theme, "_vars", map.merge($current-vars, $vars));
$themes: map.set($themes, $theme-name, $theme) !global;
@return $theme;
}
@mixin generateThemes() {
@each $theme-name, $theme in $themes {
@if list.length($theme-whitelist) == 0 or list.index($theme-whitelist, $theme-name) {
$selector: if($theme-name == "default", ":root", "[data-theme='#{$theme-name}']");
@each $mixin in $theme-mixins {
@include meta.apply($mixin, $theme);
//update the variable after the mixin has modified it
$theme: map.get($themes, $theme-name);
}
$vars: map.get($theme, "_vars");
#{$selector} {
@each $var-name, $var-val in $vars {
//only output the variable if we are default or differ from default
@if $theme-name ==
"default" or
string.index("#{$var-val}", "var(") !=
null or
map.get($themes, "default", "_vars", $var-name) !=
$var-val
{
#{$var-name}: #{$var-val};
}
}
}
}
}
}
//_alerts.scss
@mixin alerts-theme-mixin($theme) {
@each $variant in $alert-variants {
$color: theme.getColor($theme, $variant);
$foreground: rgba(color.adjust($color, $lightness: 10%), 15%);
$result: utility.alpha-blend($foreground, theme.getColor($theme, "bg"));
$theme: theme.addVariables(
$theme,
(
"--h-alert-#{$variant}-bg": $result,
"--h-alert-#{$variant}-fg": utility.getContrastColor($result),
"--h-alert-#{$variant}-border": rgba($color, 0.3)
)
);
}
}
@include theme.registerThemeMixin(meta.get-mixin("alerts-theme-mixin"));
Each theme module registers the variables in the theme map, the generateThemes
mixin then only outputs the variables that differ from the default theme
Variables depending on variables
You might have spotted the string.index("#{$var-val}", "var(") != null or
part in the if. Why always include a variable if the value includes another var?
This solves the following situation:
:root {
--h-bg: black;
--h-body: var(--h-bg);
}
[data-theme=bright] {
--h-bg: white;
}
--h-body
inside :root
uses --h-bg
which is black.
--h-body
inside [data-theme=bright]
is not defined, so it uses the declaration inside :root
but that one does NOT use the --h-bg
from the theme, it used the one from :root
which means the body background will still be black in the bright theme
So a theme now only includes variables that overwrite a default value and all variables that depend from other variables. Yes, that also includes variables that depend on variables that are not overwriting the default value:
:root {
--h-bg: black;
--h-primary: green;
--h-body: var(--h-bg);
--h-button-bg: var(--h-primary);
}
[data-theme=bright] {
--h-bg: white; /* included because it overwrites default */
--h-body: var(--h-bg); /* included because it relies on a var */
--h-button-bg: var(--h-primary); /* unnecessary */
}
But at some point it is time to stop xD