CSS Custom Properties (also called CSS variable) is having its moment under the sun. 85% of developers use it according to State of CSS 2021 and browser support hits more than 94% globally. We also see support for CSS variables everywhere from Bootstrap 5, to SaleForces's Lightning Design System, and even in stand-along frameworks such as Pollen and Open Props.
If you have heard about CSS Customer Properties but have not used it yet, or if aren't sure why you should use CSS Custom Properties over Sass Variables or other styling solutions, this is the post for you. I'll briefly go over what CSS custom variables are, then dive into how you can use them to create a style API. Finally, we'll look at some implementation tricks and pitfalls.
What is CSS Custom Properties
If you are not familiar with CSS custom properties, they are user-defined CSSOM properties that start with `--` and cascade through the page like any other CSS property. A common use-case defines
--primary-color: blue; at the
:root level of a web page and use its value like a JS or Sass value elsewhere on the site with the var() function. Since these custom properties are subject to the cascade, you can also scope them via CSS selectors to sections on the page. CSS variables provide 3 main benefits:
- DRYs up (Don't repeat yourself) your stylesheet, changing the primary color of your site now requires only a single line change.
- Aids in CSS readability by providing intent behind CSS values: compare
- Provides a style API for reusable components. For example, you may only expose the inner spacing of a button
--button-spacingto be changed but not its margins, and encourage margin changes to be done via the button's containers. You can also use this to create a dynamic relationship between various CSS properties, which prior to custom properties can only apply to font size via the
It is the third use case that I would like to explore further today.
Custom Properties as Style API
Let's take the example of a reusable button component. For this particular button, you would like to allow the users to change its button background, text color, text size and it's padding.
Furthermore, let's suppose that you would like to have a dynamic border radius on the button such that it will always be half the size of the padding so that a skinny-looking button is more square and a wider one looks more rounded.
This specific relationship between padding size and border-radius is not very different from setting the padding of an element using the
em unit, which creates a linear relationship between an element's padding and its content's font size. Prior to CSS variables, there is no way to enforce the padding/border-radius relationship in CSS, our button users will have to either stick to the default padding or risk changing padding but forgetting border-radius or vice versa.
With CSS variables, this can easily be done by setting the value of border-radius with
calc() taking in a
--spacing custom property. Note that by naming it spacing we are creating an additional layer of abstraction for a property outside of the box model.
Following the principle of defensive programming and with the power of CSS variable fallback, we also have an easy way to apply a default style via the fallback syntax
var(--property-name, default) so we have a default button style even when the custom property is not set.
Here's an example of this in action:
Getting the Shorthand of the Stick
All is well with our button component until a user came to us complaining that 'the border-radius disappeared'. It turns out, we used the CSS shorthand when declaring our component's padding width with the
--spacing property and calculated our border-radius based on an assumption that it is a single length unit. When our user attempted to change the padding value to
0.5em 1em, it broke our border-radius calculation.
We have a few potential solutions at this point:
We could disallow
--spacing values that are not a single length unit, but CSS variables have no built-in type checking and we have no way to enforce this. CSS Houdini would be great for this use-case, but it is not yet widely supported. Furthermore, this robs end-user of more fine-grained control over our button spacing.
Another approach is disallowing shorthand properties altogether and explicitly setting the individual value. This is the approach that Lightning Design System took, separating
--spacing into its components:
--spacing-inline-end. This method is more rigid and forces us to remove an established CSS shorthand away from end-users. It can potentially push users towards using CSS overrides over our variable API, which breaks both our spacing encapsulation and the padding/border-radius relationship. Furthermore, this solution forces users to use logical properties that may not be appropriate for the use-case, when we want physical and not flow relative positioning.
A better approach yet is allowing a variety of custom properties that point to the same underlying property, thus creating a flexible CSS variable API. This approach has the added benefit of mirroring existing CSS shorthands. I call this technique CSS Variable Overloading, to borrow from function overloading, where a function can take on a variety of different parameters types.
To do this, we again leverage the existing CSS custom property's fallback mechanism. However, since we cannot set multiple values as fallbacks, we need to chain them in a nested manner.
For example, if we want to set the top padding of an element using variations of the
--spacing variation, with a default value of 1em, we can do this:
--padding-top: var(--spacing-top, var(--spacing-block-start, var(--spacing-block, var(--spacing, 1em))));
Note: you need to nest your custom properties from most specific to least specific to get the correct fallback behavior.
CSS variable overloading is not without drawbacks, here's a non-exhaustive list (and how to mitigate them):
It is not clear that there's always an intuitive and correct fallback order for the CSS variables. While specific over generic selectors is a natural choice, in some cases, such as the logic vs absolute positioned properties, there isn't a clear winner and it is important to stay consistent to avert confusion.
Each CSS fallback naturally increases your final CSS output size, sometimes unnecessarily so. This can be best mitigated via the usage of tooling during the build step to remove unused CSS custom properties.
With every additional overloadable CSS custom property name, we risk having additional cognitive load for the user, along with more difficulties in API discovery. This is best mitigated by resolving to either mirror our usage of variable overloading to existing CSS properties or be consistent throughout a codebase so there isn't a large API discovery overhead.
Phew, that was a lot. I hope you will give CSS APIs a shot next time you see a linear relationship between CSS properties in your designs. And if you already expose styling hooks via custom properties, provide CSS variable overloading to improve their user experience.
If you are enjoyed this article, you may also enjoy reading about ways to improve your Front-End documentation.