CSS custom properties (CSS variables)

Posted on
Tags: CSS

As engineers, we strive to avoid code repetition and set variables accordingly. With CSS, we have the powerful feature of CSS custom properties, also known as CSS variables. These variables offer significant benefits, such as improving code readability, facilitating maintainable global changes, and providing dynamic control over styles in a live environment. Now, let us delve deeper into this topic and gather valuable insights from MDN.

What are CSS variables?

CSS authors define CSS variables, also known as CSS custom properties, as entities containing specific values for reuse throughout a document. You set them using custom property notation (e.g., --color: black;) and access them using the var() function (e.g., color: var(--color);).

CSS variables surpass SASS/Less variables in power and flexibility due to their live nature. They respond to the DOM’s cascade, inheritance, and specificity rules. Unlike SASS variables, you can modify CSS variables with JavaScript or apply them directly in the browser.

The selector where you declare a CSS variable determines its scope. If you declare it in the global scope (i.e., on :root), all elements on the page can access the CSS variable. If you declare a CSS variable inside an element selector, it is accessible only to that element and its descendants. However, a descendant can override it.

Here is an example of how to use them:

:root {
	--color: black;
}

h1 {
	color: var(--color);
}

Initially, we declare a :root variable. Subsequently, we retrieve the value using the var() function.

What else we can do:

:root {
	--color: black;
}

h1 {
	--color: green;

	color: var(--color);
}

p {
	color: var(--color);
}

The h1 scope confines the variable. As a result, only h1 turns green. The --color in h1 does not connect to :root. It is a new variable used exclusively within the h1 scope.

Scope

Grasping the scope of CSS in the context of CSS variables can be complex. The intricacy stems from our comprehension of cascading (the “C” in “CSS”) and how it interplays with selectors and variables.

Here is a simple example:

body {
	--bg-color: red;

	background-color: var(--bg-color);
	color: black;
}

div {
	background-color: var(--bg-color);
}

/* paragraph inside div container */
p {
	background-color: var(--bg-color);
}

Both p and div inherit the color and --bg-color value from body. Overriding the variable must be done inside the scope of the selector:

body {
	--bg-color: red;

	background-color: var(--bg-color);
	color: black;
}

div {
	background-color: var(--bg-color);
}

/* paragraph inside div container */
p {
	--bg-color: blue;

	background-color: var(--bg-color);
	color: inherit;
}

p has a new background, not related to other elements.

CSS variables, cascading, scope, selectors, inheritance, and overriding. Grasping these concepts is essential for effectively using CSS variables.

Nested scope

CSS allows us to write nested selectors, like SCSS, LESS, or other CSS processors. However, understanding the logic of scope and selectors can be daunting. Remember, cascading still applies here.

Here is an example:

div {
	--color: red;
	color: var(--color);

	& p {
		--color: green;
	}
}

The div container color is red, and the paragraph color inherits red. In short, it goes from black (root) -> red (div) -> red (p). We cannot override the variable in div. Let us rewrite the code to understand why:

div {
	--color: red;

	color: var(--color);
}

div p {
	--color: green;
}

The selector is different and does not belong to the scope of div but to the div p selector. The color of p is red, inherited from the div.

Parent variables

There is a way to select a parent of a child selector using the functional :has() pseudo-class. Check it out and use it cautiously.

Overwriting parent variables

Let us create a simple example:

.parent {
	--color: green;

	color: var(--color);
}

/* ❌ Not overwriting variable in .parent */
.child {
	--color: red;
}

/* ✅ Overwriting variable in .parent */
.parent:has(.child) {
	--color: red;
}

It works! However, be aware that more complex examples can complicate testing and maintenance. Be careful with this strategy.

Test pseudo-classes in your web framework (Vue, Angular, etc.). The results might differ from what you expect based on the support.

Harnessing the power of variable overrides

We have seen how to override a variable with a selector. There is another possibility with pseudo-classes:

a {
	--color: blue;

	color: var(--color);
}

a:hover {
	--color: red;
}

a:focus {
	--color: green;
}

:hover, :focus and any other pseudo-classes are inside the scope of the a selector, so it overrides the variable. Overwriting variables from the parent works in the same way, just by using the same name:

:root {
	/* RGB values for `red`, `green`, and `blue` */
	--color-rgb-red: 90;
	--color-rgb-green: 10;
	--color-rgb-blue: 200;
}

h2 {
	/* combine values */
	color: rgb(var(--color-rgb-red), var(--color-rgb-green), var(--color-rgb-blue));
}

h2:hover {
	/* overwrite scoped h2 variable, but not :root */
	--color-rgb-red: 200;
}

:root contains the values for the RGB color code and we use these in h2. h2:hover is in the scope of the h2 selector. This way, it can set --color-rgb-red. The advantages include not having to declare an additional variable and resulting in less code:

h2:hover {
	color: rgb(200, var(--color-rgb-green), var(--color-rgb-blue));

	/* short */
	/* --color-rgb-red: 200; */
}

The essence of the matter: one can assign any name to the variable at any given moment, even without a prior declaration, and then put it to use.

:root {
	/* missing variable! */
	/* --color-rgb-red: 90; */
	--color-rgb-green: 10;
	--color-rgb-blue: 200;
}

h2 {
	/* rgb value is invalid -> bad example! */
	color: rgb(var(--color-rgb-red), var(--color-rgb-green), var(--color-rgb-blue));
}

h2:hover {
	/* overwrite scoped variable, makes the RGB value valid */
	--color-rgb-red: 200;
}

The h2 color is not set to a color because the value is invalid. As a result, it inherits from the parent elements or falls back to default browser styling.

Remember to declare the variable with a value or use a fallback. It makes maintenance easier.

Fallback

A fallback is useful when you do not have a value set for the variable because the variable might be absent. The second argument of the var() function supports that.

Here is a simple example:

p {
	color: var(--color, blue);
}

If --color is unavailable, it uses blue, and you will not have any issues with missing variables. In case you have multiple options, you can combine them like this:

p {
	color: var(--color, var(--alternative-color, blue));
}

Fallbacks in CSS become especially important when your webpage design is flexible and relies heavily on CSS variables. Consider a scenario where you are creating a dark mode for your website. You are likely using CSS variables to define your color scheme. If these dark mode variables fail to load correctly, your webpage could become unusable. But if you have a fallback, your users will still see a well-styled interface. Using fallbacks can enhance the robustness of your CSS code. It is a strategy that ensures resilience, making your webpage reliable no matter what.

Fallback values

It is crucial to ensure that if a component has to receive values from an external source and none arrive, the component has a predefined fallback value to fall back on.

Component Example:

.component {
	color: var(--color, blue);
}

When working with a library, we aim to simplify the process. Therefore, we evaluate the fallback and associate it with the component.

/* global file */
:root {
	--color: blue;
}
/* component inherits from global file */
.component {
	color: var(--color);
}

This method is effective. It permits alterations to the global file or the ability to override the --color variable using JavaScript.

document.querySelector('.component').component.style.setProperty('--color', 'red');

Improve fallback values

Such a structure can lead to issues, with errors cropping up frequently. Imagine a scenario where the library is ready for use, the components lack fallbacks, and they are expecting values from the global file, but the variable is nowhere to be found.

What could have caused its absence? Did an update to the component occur, and the global file got overlooked because it did not account for the update? Or did the person maintaining the global file delete it? The uncertainty leads us back to the time-consuming task of debugging.

Even if we meticulously document the changes in the release notes (recommended!), the problem might persist. To avoid wasting time and to safeguard against such issues, we incorporate a fallback by default. It allows for the external injection of variables, and in case something goes awry, you still have the fallback values to rely on!

/* token variables file */
:root {
	--color: blue;
}
/* default template file */
:root {
	--color: var(--color, blue);
}
/* component file */
.component {
	color: var(--color);
}

The value moves to the component like this:

token -> template with fallback -> component

If you want to make it even more dynamic for the user, you could inject component styles and still have defaults to fall back on:

.component {
	color: var(--component-color, var(--color));
}

Structuring variables in this way makes them easier to maintain. You only need to change small parts to make updates. You do not necessarily have to delve too deep.

By using fallbacks, you can add variables from outside. You will have backup values ready if something does not work as expected. Your webpage remains functional and user-friendly.

Thinking for libraries or design systems

When developing design systems, you often need to manage many CSS variables. You might scatter these variables across different files and components. Not using proper naming conventions can create confusion and might lead to bugs. That is the point where using unique prefixes becomes significant.

Unique prefixes serve as a namespace for your variables, making them more specific and reducing the likelihood of naming conflicts. For instance, you might have a variable --color in your token variables file. Without a prefix, if there is another --color variable elsewhere in your code, it could lead to unexpected behavior.

Adding a unique prefix, such as --prefix-color, makes it clear that this variable is distinct from other --color variables in your code. You can choose a prefix that fits your context - it could correspond to a specific component, feature, or module linked to the variable.

Here is how it works in your example:

/* token variables file */
:root {
	--myLib-color: blue;
}
/* default variables file */
:root {
	--myLib-color: var(--color, blue);
	/* use other internal variables */
	--myLib-alt-color: var(--color, green);
}
/* component file */
.component {
	background-color: var(--myLib-alt-color);
	color: var(--myLib-color);
}

Using --myLib-color and --myLib-alt-color as variable names, where myLib acts as a unique prefix. Using unique prefixes clarifies that these variables belong to your library or design system. It also helps avoid naming conflicts with other --color variables in your code.

In conclusion, using unique prefixes for your CSS variables is a simple yet effective strategy to keep your code organized, maintainable, and bug-free. It is a best practice that can save you debugging time in the long run. So, when you are working on your next library or design system, remember to prefix your variables!

Sequence of variable implementation

In CSS, a variable declared later in the code can override any previously declared variable with the same name. The order in which you declare your CSS variables is significant.

Let us look at an example:

/* Token variables file */
:root {
	--prefix-color: blue;
}

/* The color of the component is set to --prefix-color */
.component {
	color: var(--prefix-color);
}

/* An external file injects a new value for --prefix-color */
:root {
	--prefix-color: red;
}

In the above code, we first declare —prefix-color as blue. Then, we use this variable to set the color of a component. However, later in the code, an external file injects a new value for —prefix-color, changing it to red.

What is the result? The color of the component now becomes red, not blue! Because of the later declaration of —prefix-color as red overrides the earlier declaration of it as blue.

This example clearly illustrates the importance of the sequence in which you declare your CSS variables. This factor can significantly influence how your styles ultimately appear on the page.

Difference between CSS processor variables and CSS variables

CSS variables are a powerful feature. CSS processor variables often require a build step to generate the output. After the build, the value replaces the variable name hardcoded. Where can this be useful?

Exploring the possibilities of altering CSS variables within browsers can lead to surprising revelations: these variables are dynamic throughout the document. CSS variables can be utilized outside custom elements, even when shadow scope is enabled. Do not miss it. Given this, a build step might be a more efficient choice. It allows scoping variables within the confines of the specific tool or framework.

Regardless of your choice (A, B, or both), be careful and mitigate potential issues from the outset: it is preferable to plan to avoid hardship.

Unleash the power of CSS Variables!

CSS variables, also known as CSS custom properties, offer a new world of possibilities. They can make your stylesheets more readable, maintainable, and dynamic. Whether you are defining colors, fonts, or layouts, CSS variables can help you write cleaner, more efficient code.

Dive in, start experimenting, and discover the power of CSS variables. Your stylesheets will thank you!