Dynamic color themes with Sass and CSS custom properties

14 March 2021

How to make use of both native CSS custom properties and Sass variables to create a flexible and easy to use color system.

Once in a while, I hear people talking about whether or not we still need Sass, now that CSS custom properties (also referred to as CSS variables) are supported in all major browsers. I think it might get a bit less useful in the future, but I also think that both can work great together. In this article I want to show you an example of how we can build an easy to use system for dynamic color themes, using both CSS custom properties and Sass variables.

Table of contents

  1. Dynamic color themes
  2. The color system
  3. Implementation
  4. Summary

Dynamic color themes

Recently, I had the fun task to implement a color system that is different on every page, depending on the main image. The website uses Drupal as its backend and the product data is imported via an external API. The products (books mainly) have a cover from which a primary color is extracted as soon as it is imported and saved to our database. I won't go into detail on how that is done, because we are using a PHP library for that, it's called ColorExtractor. The interesting part for me was to use that color variable in the frontend.

The color system

Here is a visual explanation of the color system, using a light theme and a dark theme as an example:

Example of a light and a dark theme, each showing a palette of the five color variables.

The color system basically consists of a primary and a secondary color.

The secondary color is a variant derived from the primary color. It is either 50% lighter or 25% darker than the primary color, depending on whether the primary color itself is classified as dark or light (this is also calculated by the PHP library). For example, the color value #DF7335 is classified as light, so the secondary color will be another 50% lighter: #EFB99A. That way we make sure, that the contrast color (the color that is used on top of the theme color), always has enough contrast, because both theme colors are on the same side of the threshold.

In addition to those basic colors, we need a few other variables:

  • For one, we need the above-mentioned contrast color. It will be used for things like text or icons, that appear on top of a theme color. In our case, the contrast color is either black or white. That again, depends on whether the theme is dark or light.
  • Next, we also need the opposite of that contrast color, because there are cases, where we have text on top of the contrast color. An example of that would be a button: a white button should have black text, but a black button should have white text.
  • Lastly, there is one more thing to consider. What if we want to use our main theme color not only as background color, but as text on a white background? That is not a problem, as long as the color is dark. But if the primary color is a yellow, for example, the text won't be readable anymore. For that case, we need a fifth variable: the text color. The text color will use the primary color if it's dark, and black if it's light. We could have made up another color value, like a darker variant of the primary color. But that would have raised other questions in turn (how much darker would that variant need to be, in order to be dark enough?) and probably would have made the design worse.

Implementation

Here is what we get from the backend:

  • color: The primary color extracted from the main image, for example #DF7335.
  • is_dark: Whether the color is dark or light, in this case it is light.
  • color_dark and color_light: A darker variant and a lighter variant of the primary color.

Because Drupal variables cannot directly be accessed in a CSS file, we define our CSS variables in a style tag in Twig. The theme.html.twig looks something like this:

<style>
  {{ selector ? selector : ':root' }} {
    --theme-color: {{ color }};
    --theme-color-secondary: {{ is_dark ? color_dark : color_light }};
    --theme-color-contrast: {{ is_dark ? 'white' : 'black' }};
    --theme-color-contrast-inverted: {{ is_dark ? 'black' : 'white' }};
    --theme-color-text: {{ is_dark ? color : 'black' }};
  }
</style>

The template's only purpose is to set the CSS variables. That way, we can include it wherever it's needed. The main use case would be the page level (:root), but there might also be cases, where the colors need to be used inside a child component of the page, for example a product teaser. We could then include the template, passing a selector, so that the root level variables are overridden for only that component.

{% include 'theme.html.twig' with { selector: '.product-teaser--' ~ id } %}

Of course, we also need to make sure the color variables are made available in the respective template.

But just in case, let's define a default color, in case the variable isn't set or we get an invalid value for some reason. We can set a default value, when using a CSS variable, like this:

background-color: var(--theme-color-secondary, #017449);

Unfortunately, this would be a little tedious to type out all the time by hand. So why not define some Sass variables for that?

$theme-color: var(--theme-color, #017449);
$theme-color-secondary: var(--theme-color-secondary, scale-color(#017449, $lightness: -25%));
$theme-color-contrast: var(--theme-color-contrast, white);
$theme-color-contrast-inverted: var(--theme-color-contrast-inverted, black);
$theme-color-text: var(--theme-color-text, black);

One thing to notice: we are using the Sass scale-color function to make the default value 25% darker. We could have used darken(#017449, 25%), but as it turns out, darken and lighten behave slightly different: while darken and lighten just add or subtract the given value to the lightness value of the original color, scale-color scales the original value by a percentage. Here is an article that explains this is more detail.

But what if a browser doesn't support CSS custom properties at all? We can make use of CSS default behaviour, when it comes to unsupported values: define the fallback first, then override it with the more modern approach. The latter will simply be ignored, if it is unsupported.

background-color: #017449; // Old browser uses this
background-color: var(--theme-color, #017449); // Modern browser overrides it with this

To make this even easier, we can use PostCSS to automatically prepend the fallback value during the build process. PostCSS is great for other stuff as well, and it is available for most build tools like Gulp, Webpack, etc. Here is how we could handle this using a Gulp task:

const sass = require('gulp-sass');
const postcss = require('gulp-postcss');
const postcssCustomProperties = require('postcss-custom-properties');

function styles() {
  return gulp.src('./src/styles/main.scss')
    .pipe(sass())
    .pipe(postcss([
      postcssCustomProperties()
    ]))
    .pipe(gulp.dest('./build'))
}

exports.default = styles;

After the main.scss file is compiled by Sass, the CSS is passed to PostCSS, adding the postcss-custom-properties plugin. The processed file is then copied to a build folder.

Finally, we don't need to worry about the fallbacks anymore and we can simply use our "good old" Sass variables, even though we are working with dynamic values:

.header {
  background-color: $theme-color-secondary;
  color: $theme-color-contrast;
}

Easy. And here is what gets compiled in the final CSS:

.header {
  background-color: #017449;
  background-color: var(--theme-color-secondary, #017449);
  color: white;
  color: var(--theme-color-contrast, white);
}

The first value is used when CSS custom properties are not supported at all. The second parameter of the var() is used when --theme-color-secondary is not defined or invalid. In all other cases, whatever is defined as --theme-color-secondary is used.

Let's see, how that looks like on the final website.

A detail from the finished website, showing how the color theme is applied in practice.

Summary

We created a flexible color theme system, which can be included on any page or even on a child component. It makes sure that the contrast ratio is acceptable, regardless of the color that is used. And it also has a nice fallback for older browsers: a browser that doesn't support CSS variables, can still access all our content and everything should look nice, except every page uses the same colors. So, no fancy color coded pages in IE, but if that is the only drawback, I think that is totally acceptable.

More blog posts