css

Generating Configurable Light & Dark Themes Using CSS Variables, calc() and HSL

Cover image of light and dark modes with a css code backgroung

Creating two separate themes for light and dark modes can be a bit of a pain 😣. Wouldn't it be great if you could create one of them and the other is generated automatically 🤔? What if I told you that was possible, and even better, that you could change the colour whenever you wanted with no extra work 🤯? To do this we're going to combine CSS custom properties (AKA css variables), calc() and HSL.

The Basics

The basic concept is that we set colour variables, use them in our css for text colour, backgrounds, etc. then reverse them in a media query for prefers-color-scheme.

1:root { 2 --shade-0: white; 3 --shade-100: black; 4} 5 6html { 7 color: var(--shade-100); 8 background-color: var(--shade-0); 9} 10 11@media (prefers-color-scheme: dark) { 12 :root { 13 --shade-0: black; 14 --shade-100: white; 15 } 16}

Most websites use more than just black and white in their theme though, so let's take it a step further...

Overview of HSL

HSL stands for Hue, Saturation & Lightness. That means for a given hue, we can independently set the saturation and lightness.

Take this example where we pick a hue value of 0 (red) and create a washed out pink, bright red and a red-tinted dark shade by changing the saturation and lightness values.

1--light-pink: hsl(0, 20%, 90%); 2--bright-red: hsl(0, 100%, 50%); 3--dark-red: hsl(0, 30%, 10%);
c

You could swap the hue value of 0 for 200 and have a similar set of shades generated in blue.

image of the blues generated by the hsl values in the code above

Generating shades

Using HSL we can set the base colour in a css variable and use that to create a series of shade variables to use in our theme.

1:root { 2 --base-color: 200; 3 4 --shade-0: hsl(var(--base-color), 0%, 100%); 5 --shade-10: hsl(var(--base-color), 5%, 90%); 6 --shade-20: hsl(var(--base-color), 10%, 80%); 7 --shade-30: hsl(var(--base-color), 15%, 70%); 8 --shade-40: hsl(var(--base-color), 20%, 60%); 9 --shade-50: hsl(var(--base-color), 25%, 50%); 10 --shade-60: hsl(var(--base-color), 30%, 40%); 11 --shade-70: hsl(var(--base-color), 35%, 30%); 12 --shade-80: hsl(var(--base-color), 40%, 20%); 13 --shade-90: hsl(var(--base-color), 45%, 10%); 14 --shade-100: hsl(var(--base-color), 100%, 0%); 15}

You can play around with the saturation and lightness values to get the tones and shades that suit your design. I chose to lower the saturation as the lightness increased as that created a set of subtly-tinted shades that worked great as background and text colours in my theme.

Just like in the basic example we reverse the shades in our media query for the dark theme.

1@media (prefers-color-scheme: dark) { 2 :root { 3 --shade-0: hsl(var(--base-color), 100%, 0%); 4 --shade-10: hsl(var(--base-color), 45%, 10%); 5 --shade-20: hsl(var(--base-color), 40%, 20%); 6 --shade-30: hsl(var(--base-color), 35%, 30%); 7 --shade-40: hsl(var(--base-color), 30%, 40%); 8 --shade-60: hsl(var(--base-color), 20%, 60%); 9 --shade-70: hsl(var(--base-color), 15%, 70%); 10 --shade-80: hsl(var(--base-color), 10%, 80%); 11 --shade-90: hsl(var(--base-color), 5%, 90%); 12 --shade-100: hsl(var(--base-color), 0%, 100%); 13 } 14}

Something to note is I don't bother to redefine --shade-50 as that keeps the same values between light and dark modes due to the number of shades and the lightness values I chose.

Making the shades configurable

If you wanted a more vivid theme you could bump up the saturation values in each shade. However, in the spirit of configurability, let's set a base saturation variable and multiply it in our shades using calc. That way you can adjust the saturation of the whole palette in one place.

1:root { 2 --base-color: 200; 3 --base-saturation: 5%; 4 5 --shade-0: hsl(var(--base-color), 0%, 100%); 6 --shade-10: hsl(var(--base-color), calc(var(--base-saturation) * 1), 90%); 7 --shade-20: hsl(var(--base-color), calc(var(--base-saturation) * 2), 80%); 8 --shade-30: hsl(var(--base-color), calc(var(--base-saturation) * 3), 70%); 9 --shade-40: hsl(var(--base-color), calc(var(--base-saturation) * 4), 60%); 10 --shade-50: hsl(var(--base-color), calc(var(--base-saturation) * 5), 50%); 11 --shade-60: hsl(var(--base-color), calc(var(--base-saturation) * 6), 40%); 12 --shade-70: hsl(var(--base-color), calc(var(--base-saturation) * 7), 30%); 13 --shade-80: hsl(var(--base-color), calc(var(--base-saturation) * 8), 20%); 14 --shade-90: hsl(var(--base-color), calc(var(--base-saturation) * 9), 10%); 15 --shade-100: hsl(var(--base-color), 100%, 0%); 16}

Considering Fixed Shades

Sometimes you may want shades to remain the same between light and dark modes. For example you may have a button styled with your brand colour that doesn't change between modes but you want the button text to make use of your theme's shade variables for consistency. If we used --shade-90 the button text colour would swap between light and dark, when we perhaps want it to always be dark.

To fix this we can create a duplicate set of shades, let's call them shifting shades, and set their initial values to reference the originals. Then in our media query we adjust only the shifting shades. In our css we use the shifting shades when we want them to change between light and dark modes, and the original shades when we want fixed values.

1:root { 2 --base-color: 200; 3 --base-saturation: 5%; 4 5 --shade-0: hsl(var(--base-color), 0%, 100%); 6 --shade-10: hsl(var(--base-color), calc(var(--base-saturation) * 1), 90%); 7 --shade-20: hsl(var(--base-color), calc(var(--base-saturation) * 2), 80%); 8 --shade-30: hsl(var(--base-color), calc(var(--base-saturation) * 3), 70%); 9 --shade-40: hsl(var(--base-color), calc(var(--base-saturation) * 4), 60%); 10 --shade-50: hsl(var(--base-color), calc(var(--base-saturation) * 5), 50%); 11 --shade-60: hsl(var(--base-color), calc(var(--base-saturation) * 6), 40%); 12 --shade-70: hsl(var(--base-color), calc(var(--base-saturation) * 7), 30%); 13 --shade-80: hsl(var(--base-color), calc(var(--base-saturation) * 8), 20%); 14 --shade-90: hsl(var(--base-color), calc(var(--base-saturation) * 9), 10%); 15 --shade-100: hsl(var(--base-color), 100%, 0%); 16 17 --shifting-shade-0: var(--shade-0); 18 --shifting-shade-10: var(--shade-10); 19 --shifting-shade-20: var(--shade-20); 20 --shifting-shade-30: var(--shade-30); 21 --shifting-shade-40: var(--shade-40); 22 --shifting-shade-50: var(--shade-50); 23 --shifting-shade-60: var(--shade-60); 24 --shifting-shade-70: var(--shade-70); 25 --shifting-shade-80: var(--shade-80); 26 --shifting-shade-90: var(--shade-90); 27 --shifting-shade-100: var(--shade-100); 28} 29 30@media (prefers-color-scheme: dark) { 31 :root { 32 --shifting-shade-0: var(--shade-100); 33 --shifting-shade-10: var(--shade-90); 34 --shifting-shade-20: var(--shade-80); 35 --shifting-shade-30: var(--shade-70); 36 --shifting-shade-40: var(--shade-60); 37 --shifting-shade-60: var(--shade-40); 38 --shifting-shade-70: var(--shade-30); 39 --shifting-shade-80: var(--shade-20); 40 --shifting-shade-90: var(--shade-10); 41 --shifting-shade-100: var(--shade-0); 42 } 43}

Final Thoughts

And that's it! We've got a set of 10 colour-configurable shades to use in our theme that automatically switch between light and dark modes 🎨🌞🌚.

Setting Up Aliases

It can be a bit hard to remember what all your shades are sometimes, so to make things a bit easier I like to create variables for common use cases like text colours and often-used backgrounds. They just reference the shifting shades but are easier to remember and allow you to update all the elements that use it in one place easily.

1--text-color: var(--shifting-shade-90); 2--text-color-alt: var(--shifting-shade-10); 3 4--background: var(--shifting-shade-10); 5--background-alt: var(--shifting-shade-90);

Be The Alpha

As well as HSL, there's HSLA. The A stands for Alpha, meaning you could also generate versions of your shades with different opacity levels. To keeps things DRY you could store the H, S and L parts in a variable, then use that same variable in both HSL and HSLA. Also check out my codepen demo of this.

1--red-hsl: 0, 50%, 50%; 2--red: hsl(var(--red-hsl)); 3--red-50: hsla(var(--red-hsl), 50%);