Local Themes using CSS Custom Properties

Published
Categories

This is an idea inspired by Expressive Design Systems, with some additional help from A Strategy Guide to CSS Custom Properties and Theming With Variables: Globals and Locals.

The design system I work with is largely used for more content-heavy marketing sites, which provides some interesting challenges that I haven’t seen often in my research into other design systems. Many of our sites have pages that are broken up into sections differentiated by separate background colors. The background colors that are available vary based on the brand colors set up for each site, and usually there’s a good mix of dark and light colors.

We don’t really want to limit which components can be used on which backgrounds so we need to set up a system that allows components to adjust themselves based on the background color they are on. Currently we have a “light” and a “dark” version of some components, but this requires a lot more work to make sure we’re using the right version and can still have readability issues on some backgrounds. So instead I have been prototyping a way to use CSS Custom Properties to build our own system to locally “theme” specific areas of the page, providing intentional, balanced combinations of colors based around each background.

Grid display of boxes containing a small assortment of inner elements, with each box having a unique color scheme.
Example color swatches

The power comes from the fact that CSS Custom Properties are inherited, just like the color property. And just as color affects all text color inside an element, we can create a standard set of properties that affect the color for any other aspect of our inner components, no matter how deeply they are nested. Our main design system styling is updated to include references to custom properties:

[class*="u-theme--"] {
  background-color: var(--ds-theme--bg-color);
  color: var(--ds-theme--text-color);
}

:focus {
  outline: 3px solid var(--ds-theme--focus-ring-color, currentColor);

hr {
  border-color: var(--ds-theme--accent-color, currentColor);
}

.ds-c-icon {
  color: var(--ds-theme--accent-color);
}Code language: JavaScript (javascript)

Then in a particular site’s styling that’s loaded on top of the core, we can configure as many different combinations as we want with that site’s list of available colors:

.u-theme--blue {
  --ds-theme--bg-color: var(--ds-color--blue);
  --ds-theme--text-color: var(--ds-color--white);
  --ds-theme--accent-color: var(--ds-color--yellow);
  --ds-theme--focus-ring-color: var(--ds-color--light-gray);
}Code language: CSS (css)

Layers of Specificity

To make sure that everything is still presentable if only some colors are specified, we should provide fallbacks in the references to our custom properties, while keeping in mind that any chain of var() references that ends up being undefined will revert to the initial value of the property it’s being set on. We can also include references in our core styling that are more specific than the general theme colors, which gives us an easy styling hook for customization of specific colors in components.

.ds-c-accordion {
  &__trigger {
    background-color: var(--ds-c-accordion--bg-color, var(--ds-theme--text-color));
    color: var(--ds-c-accordion--text-color, var(--ds-theme--bg-color));

    // styled to look like an icon
    &::before {
      border-color: var(--ds-c-accordion--icon-color, var(--ds-theme--accent-color, currentColor));
    }
  }
}Code language: PHP (php)

In this example, the ::before pseudo-element of the accordion trigger acts as an icon and uses the accent color if not given its own explicit color. If even the accent color doesn’t exist, it defaults to using the text color through the use of the currentColor keyword. Now we can think of our custom properties as living in different layers, from least specific to most specific.

  1. Global Constants: This is the list of raw brand colors, provided at the root of the document.
    Example: --ds-color--leaf-green: #4fc758;
  2. Local Theme: List of theme properties that are each assigned a global brand color, declared on the root element for a new background.
    Example: --ds-theme--accent-color: var(--ds-color--leaf-green);
  3. Local Component Overrides: Component-specific properties that override a more general theme color, optionally declared on the root element for a new background.
    Example: --ds-c-accordion--icon-color: var(--ds-color--berry);

Nested Sections

We might run into situations where we have one background color theme within another. Some examples include:

  • A large layout object that has a background color applied, with an inner section having a different background applied.
  • Cards with a lighter background inside a section that has a dark background.
  • A form that needs a bright background nested inside a section with a more muted background.

Thanks to the inheriting nature of custom properties, this mostly works as expected. However, the one exception is if you are using component-specific overrides. If those are defined on a theme applied to the outer section and not on the theme used for the inner section, the expected behavior would be that the components in the inner section fall back to accent color or whatever general color they would normally use. However the component-level custom property is still being inherited.

In order to make sure that any Local Overrides from a parent section don’t bleed into child sections, we need to reset any local overrides to initial, which will force it to fall back to the next default value in the chain. This can be set up using an attribute selector for every new background color section:

[class*="u-theme--"] {
  --ds-c-accordion--bg-color: initial;
  --ds-c-accordion--text-color: initial;
  --ds-c-accordion--icon-color: initial;
  // etc.
}Code language: JavaScript (javascript)

That’s one thing to keep in mind when creating points of customization within components. Currently in the project that I’m working on there’s not a ton of customization that we want to set up like this, but I could see these resets getting out of hand. It would be really nice if there was a syntax for custom properties to apply values to many at once, similar to the all property. Maybe something like --ds-c-*: initial, although thinking about how custom properties work I’m not sure that would really make sense.