Michelle Barker Front end developer at Ordoo, blogger at @CSSInRealLife

How to create better themes with CSS variables

7 min read 1978

CSS variables (also known as custom properties) are part of a relatively recent CSS specification and are rapidly growing in popularity thanks to their now-widespread browser support (with a few exceptions that we’ll visit later). They allow us to define values for reuse throughout a CSS file — something that had previously only been possible with preprocessors like Sass and LESS. In this article, we’ll explore what makes CSS variables special and some of the ways in which they can benefit us today.

What is theming?

A great use case for CSS variables is theming. When we talk about theming, we mean styling various aspects of our website in a different way, while still maintaining the overall look and feel. This often involves changing the colors, but sometimes includes fonts or icons.

One example that has recently become popular is switching between a light and dark theme. A user can click or tap on a button to reverse the colors of the site from dark text on a light background to light text on dark. There may be subtle changes too, like switching to a different highlight color to maintain sufficient contrast with the background.

See the Pen
CSS Theme Switcher
by Michelle Barker (@michellebarker)
on CodePen.

Another example is you might style most of your site’s pages with brand colors, but special event pages have a different color theme. Let’s look at how we can use CSS variables to help us with cases like these.

What makes a CSS variable?

CSS variables differ from preprocessor variables in a number of ways. CSS variables are dynamic variables: Once set, their value is not fixed. They can be updated with CSS or Javascript — so the same variable could have many different values within different selectors in your stylesheet. Here we’re defining a variable we’ll call --primaryColor in three different places. We’re setting the background-color property to the value of --primaryColor for the body and button elements.

body {
--primaryColor: #d452f2;
background-color: var(--primaryColor);
}
button {
background-color: var(--primaryColor);
}
.my-element {
--primaryColor: #1d00ff; // the button inside `.my-element` will be blue
}
.my-element:nth-child(2n) {
--primaryColor: #82f263; // the button inside the second child will be green
}

See the Pen
CSS variables and inheritance
by Michelle Barker (@michellebarker)
on CodePen.

In contrast, attempting the same thing with Sass is more convoluted. We need to define the variable and use it explicitly each time we want to change the value. It’s not dynamic in the same way:

$primaryColor: #d452f2; 

body {

background-color: $primaryColor;

}

button {

$primaryColor: #1d00ff;

background-color: $primaryColor;

}
.my-element:nth-child(2n) {

button {

$primaryColor: #82f263;

background-color: $primaryColor;

}

}

Another difference is that they can only be used for CSS property values — unlike preprocessor variables, which can be used within selector names and media query declarations, for example. We can’t do this with CSS variables, for example:

@media (min-width: var(--mediumBreakpoint)) {

/* Code here */

}

This makes it more obvious why they are known as custom properties.

The big difference is that preprocessor variables are compiled at the pre-processing stage before your code is sent to the browser. The browser will never see the variable itself, only its computed value.

Consider the following Sass code:

$primaryColor: #82f263;
.box {
background-color: $primaryColor;
}

If we inspect this in the browser, we see the following:

.box {
background-color: #82f263;
}

Conversely, the value of a CSS variable is not computed until your code is being parsed by the browser. Inspecting the code when using a CSS variable instead will show the value itself as a variable:

.box {
background-color: var(--primaryColor);
}

How to scope CSS variables

We can define a CSS variable on the :root element, like this:

:root {
--bgColor: hsl(20, 50%, 60%);
}

The :root element is equivalent to the html element, but with higher specificity — making this a global variable.

Then we can use the variable within a selector:

body {
background-color: var(--bgColor);
}

In this example the background-color of the body element will be set to the value of the variable we defined above (a fetching peach color).

See the Pen
Basic CSS variable use
by Michelle Barker (@michellebarker)
on CodePen.

Defining variables doesn’t have to be done globally, however. We could define the same variable within a selector:

.my-component {
--bgColor: hsl(20, 50%, 60%);
background-color: var(--bgColor);
}

Now we are specifically scoping the variable --bgColor to the .my-component class, so an element with this class will have our peach background-color. Here we’re defining and using the variable within the same selector, which you might think doesn’t make much sense. But it also means that children of that element will inherit the variable.

.my-component {
--bgColor: hsl(20, 50%, 60%);
background-color: var(--bgColor);
}
.my-component h2 {
color: var(--bgColor);
}

We already have the beginnings of a theme. Perhaps we want to update that variable for a special variant of our component:

.my-component.special {
--bgColor: hsl(20, 100%, 30%);
}

In this variant of our component, the h2 will inherit the new colour without us having to set it explicitly.

Scoping the variable to the selector has some advantages:

1. We can define and use --bgColor as an entirely separate variable elsewhere in our CSS (e.g. on another completely separate component) without needing to worry about it conflicting

2. Defining the variable in the selector allows all of the children to inherit the variable unless it is redefined further down the tree, while preventing its value “leaking” out to the global scope

There is nothing wrong with defining variables in the global scope per se if you have variables that you want to reuse throughout the whole project. But in those cases, it might be a good idea to give them a more unique name than --bgColor, to save you running into issues down the line!

How to use default values

Scoping a variable to a selector can increase the likelihood of accidentally using a variable you haven’t yet defined. What happens in this case?

.my-component {
/* The `background-color` value here will be `transparent` */
background-color: var(--bgColor);
}

If a variable is undefined, then the property won’t take effect. But rather than falling back to a value previously defined in the cascade, it will take on the property’s _initial_ value — in the case of our background-color property here, that would be transparent. To avoid unintended consequences, it can be useful to set a default value as the second argument:

.my-component {
background-color: var(--bgColor, hsl(20, 50%, 60%));
}

.my-component.special {
--bgColor: hsl(20, 100%, 30%);
}

Now we don’t even have to define our variable until the point where we want to update the value, and our code is even more concise. The first instance of our component will simply take on the default value. You can even set multiple default values:

.my-component {
/* Peach color background */
background-color: var( — bgColor, var( — highlight, hsl(20, 50%, 60%)));
}

.my-component.special {
/* Burgundy */
— bgColor: hsl(20, 100%, 30%);
}

.my-component.highlight {
/* Yellow */
— highlight: hsl(50, 100%, 60%);
}

With the above code, the first component’s background-color property value will fall back to the original peach color, as neither of the variables have been defined. The second component variant has the --bgColor variable defined, while the third variant only has the --highlight variable defined, so will be bright yellow.

See the Pen
Basic CSS variable use with defaults
by Michelle Barker (@michellebarker)
on CodePen.

One drawback to this approach is it can be less readable. If you define your variables upfront it can be easier to scan over the code and see at-a-glance what the value of that variable is. But it’s just a matter of personal preference. There can also be performance implications if the browser has to parse multiple levels of variables, so that could be a consideration if you’re working with a large codebase.

Variables and HSL

This demo shows a collection of cards themed with CSS variables. In it, I’m defining three variables for our base color, darker version of that color, and a highlight color. Then I’m updating those variable values for a second variant of the card:

.card {
--th: hsl(259, 72%, 64%);
--thDark: hsl(259, 72%, 38%);
--accent: hsl(259, 72%, 38%);
}
.card--event {
--th: hsl(180, 72%, 64%);
--thDark: hsl(180, 72%, 38%);
}

If you look closely you’ll see that there is a lot of repetition within our variable values. CSS variables are not just beneficial for whole property values, however. They can also help make our code more DRY (don’t repeat yourself) by enabling us to reuse values.

You might notice I’m using the hsl() function for my --bgColor variable values. HSL (hue, saturation, lightness) may be a little less commonly used, but the format makes it relatively simple to adjust colors in an intuitive way. It is ideal for working with CSS variables.

We can use variables within the hsl() function:

.my-component {
background-color: hsl(20, 50%, var(--lightness, 60%));
}
.my-component.dark {
--lightness: 40%;
}

Here we’re adjusting the --lightness variable to give us a darker variant of our component.

How to create themes with CSS variables

With HSL we can more efficiently apply our themes, and make it easier to add other color variants in the future. See the full demo here:

.card {
--hue: 259;
--th: hsl(var(--hue), 72%, var(--lightness, 68%));
--thDark: hsl(var(--hue), 72%, 38%);
--accent: hsl(var(--hueAccent, var(--hue)), 72%, 38%);
}
.card--event {
--hue: 180;
--hueAccent: 259;
}
.card:hover {
--lightness: 45%;
}

With new variants, we can simply update the --hue variable and have our color theme apply to that card. I’m also using a variable for --lightness, which makes it easy to add a hover state.

Complementary colors

We could enhance our theme by picking a complementary color for our accent color on our cards. With CSS variables and calc() we can use the initial --hue value to calculate the complementary color — that is, the opposing hue from the other side of the color wheel. We need to rotate the hue 180 degrees. Not only can we use calc() with CSS variables, we can also calculate variables from other variables as well!

.card {
--hue: 259;
--hueComplementary: calc(var(--hue, 20) + 180);
--th: hsl(var(--hue), 72%, 64%);
--thDark: hsl(var(--hue), 72%, 38%);
--accent: hsl(var(--hueComplementary, var(--hue)), 72%, 64%);
}

See the Pen
Theming cards with CSS Variables, HSL, complementary color
by Michelle Barker (@michellebarker)
on CodePen.

One cool thing about HSL is that if we choose to we can use turn units instead of degrees for the hue value, which in some cases are more intuitive. While I’m sticking with degrees here, using turn units for the hue value can be easier to visualize, especially when we get into higher numbers. Here’s a great article about CSS rotation units.

Browser support

The techniques used in this article have good support in modern browsers. However, IE11 and below do not support CSS variables. If you need to support older browsers and your color themes are an important part of your website (e.g., for accessibility, or following strict brand guidelines) then using CSS variables may not be suitable — or, at the very least, you should provide a user-friendly fallback. One way you can handle browser support is by using feature queries:

.my-component {
background-color: #82f263;
}
@supports (--css: variables) {
.my-component {
--myVariable: #ef62e6;
background-color: var(--myVariable);
}
}

The value for the @supports declaration doesn’t matter too much, as long as the syntax matches.

You should always consider a progressive enhancement approach when using newer CSS properties.

Plug: LogRocket, a DVR for web apps

LogRocket is a frontend logging tool that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen, or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.

In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page apps.

Try it for free.

Michelle Barker Front end developer at Ordoo, blogger at @CSSInRealLife

Leave a Reply