CSSIntermediate

CSS Variables (Custom Properties): Design Tokens and Theming

Store colors, spacing and radii in CSS variables, reuse them with var(), override them in any scope and build a themeable design system with a map of classes.

All CSS lessons

What you will learn

Imagine a website that uses the same blue in 40 places. When the client says “make it a little darker”, you have to find and change 40 lines, and you will miss one. CSS variables (officially custom properties) solve this: you write the blue once, give it a name, and use that name everywhere. Change the one line and the whole site updates. But variables are far more than a search-and-replace tool. Because they follow the cascade, you can redefine them in any part of the page, which is the foundation of themes, dark mode and flexible components. In this lesson you will learn how to declare and use variables, how scope and inheritance work, fallbacks, naming, and a handful of gotchas.

The HTML we will style

<h1 class="page-title">Variables Lab</h1>

<!-- Part 1: a card and buttons that use shared variables -->
<article class="card">
  <h2 class="card-title">Design tokens</h2>
  <p class="card-text">The colors, spacing and radius of this card all come from CSS variables.</p>
  <button class="btn btn-primary">Primary</button>
  <button class="btn btn-secondary">Secondary</button>
</article>

<!-- Part 2: the SAME card inside a section that overrides the variables -->
<section class="theme-ocean">
  <article class="card">
    <!-- exactly the same card HTML as above -->
  </article>
</section>

<!-- Part 3: a component with a "knob" variable -->
<p class="badges">
  <span class="badge">Default</span>
  <span class="badge badge-warning">Warning</span>
  <span class="badge" style="--badge-bg: seagreen;">Inline custom</span>
</p>

<!-- Part 4: a spacing scale -->
<div class="spacing">
  <div class="space-box space-sm">--space-1</div>
  <div class="space-box space-md">--space-2</div>
  <div class="space-box space-lg">--space-3</div>
</div>
Selector Element Role
:root the <html> element (the top of the page) where the global variables are declared
.card, .card-title, .card-text both <article>s a component that reads the variables
.btn, .btn-primary, .btn-secondary the buttons in each card components that read the variables
.theme-ocean the <section> in Part 2 a scope that overrides some variables
.badges and .badge the paragraph and its three spans a component with its own variable --badge-bg
.badge-warning the second badge changes the variable for one variant
style="--badge-bg: seagreen;" the third badge sets the variable directly on one element
.spacing, .space-box the wrapper and the three boxes use the spacing scale variables
.space-sm, .space-md, .space-lg each box each uses a different step of the scale

Declaring and using a variable

A variable has two sides: declaring (giving it a value) and using (reading it).

/* DECLARING: the name starts with two dashes */
:root {
  --color-primary: royalblue;
  --radius: 12px;
}

/* USING: read it with var() */
.btn-primary {
  background-color: var(--color-primary);
  border-radius: var(--radius);
}
  • A custom property name always starts with two dashes: --color-primary. That is what makes it a variable and not a normal property.
  • Names are case-sensitive: --Color and --color are different.
  • The value can be almost anything: a colour, a length, a number, a string, even a list of values.
  • You read it with the var() function, and you can use it anywhere a value is allowed.
  • Where you declare a variable decides who can use it. :root is the top of the document, so variables declared there are available everywhere. :root means the same element as html, but with slightly higher specificity.

Now the card from the practice page reads all its look from variables:

/* the <article class="card"> */
.card {
  padding: var(--space-3);
  margin-bottom: var(--space-3);
  background-color: var(--color-surface);
  border: 1px solid var(--color-border);
  border-radius: var(--radius);
}

Change --radius: 12px to 0 in one place and every rounded thing that uses it becomes square.

Why variables are better than repeating values

  • One change, everywhere. Edit a value in one line.
  • Consistency. Using named values (--space-2) instead of a random mix of 15px, 16px and 18px keeps a design tidy.
  • Readable code. var(--color-primary) says what it is. #2b4acb does not.
  • They are alive. Unlike variables in preprocessors such as Sass (which disappear when the CSS is built), CSS variables exist in the browser. They follow the cascade and can be changed by media queries, by other rules, and by JavaScript. You can even edit them in DevTools and watch the page update.

Scope and inheritance: the superpower

CSS variables are inherited like color and font-family. A variable declared on an element is available to that element and all its descendants. Any descendant can redefine it, and its own descendants then see the new value.

Our .theme-ocean section does exactly that:

/* global defaults */
:root {
  --color-primary: royalblue;
  --color-surface: white;
  --color-border: #e2e8f0;
  --color-text: #1e293b;
}

/* the <section class="theme-ocean"> overrides four of them */
.theme-ocean {
  --color-primary: teal;
  --color-surface: #ecfeff;
  --color-border: #99f6e4;
  --color-text: #134e4a;

  padding: var(--space-3);
  background-color: #cffafe;
  color: var(--color-text);
}

The card inside .theme-ocean is the same HTML with the same .card rule, and yet it turns teal, because everything it reads through var() now resolves to the nearest definition: the one on .theme-ocean. The card outside keeps the blue values.

This is the idea behind every theme. You write each component once using variables, then change the variables per context: a dark section, a seasonal banner, a different brand colour for each client, or a whole dark mode (the next lesson).

Where does the value come from? When the browser reads var(--color-primary) on an element, it looks at that element, then its parent, then its grandparent, and so on, and takes the first definition it finds. That is plain inheritance.

Fallback values

var() takes an optional second argument, used when the variable is not defined:

.card {
  border-radius: var(--radius, 8px);
}

If no --radius exists anywhere above the card, the card uses 8px. A fallback can itself be another variable: var(--radius-card, var(--radius, 8px)).

Note: the fallback is used when the variable is missing, not when its value is wrong. Writing --radius: banana is a valid declaration, so no fallback happens, and the property using it becomes invalid.

Variables with maths and functions

Variables work inside calc(), clamp(), min(), max() and colour functions:

:root {
  --space-2: 1rem;
}

.card {
  padding: calc(var(--space-2) * 2);           /* 2rem */
  margin-bottom: calc(var(--space-2) / 2);     /* 0.5rem */
}

You can also store part of a colour. This is a favourite trick for building palettes from one number:

:root {
  --hue: 220;
}

.btn-primary {
  background-color: hsl(var(--hue) 80% 45%);
}

.btn-primary:hover {
  background-color: hsl(var(--hue) 80% 35%);   /* the same hue, just darker */
}

Change --hue to 160 and every colour that uses it shifts from blue to green, keeping the same saturation and lightness.

Components with “knobs”

A good component exposes variables as settings, with a sensible default. The badge in our page is a small example:

/* all <span class="badge"> elements */
.badge {
  --badge-bg: var(--color-primary);       /* the default: use the theme colour */

  display: inline-block;
  padding: 0.25rem 0.75rem;
  border-radius: 999px;
  background-color: var(--badge-bg);
  color: white;
  font-size: 0.875rem;
}

/* the variant just changes the knob */
.badge-warning {
  --badge-bg: orange;
}

The .badge-warning rule does not repeat the whole badge style. It only sets the variable, and the main rule picks it up. You can even set the variable directly on one element with an inline style, which is a legitimate use of inline styles:

<span class="badge" style="--badge-bg: seagreen;">Inline custom</span>

The inline value is closest to the element, so it wins. This keeps the CSS in the stylesheet and lets the HTML (or JavaScript) supply just the changing value.

Organising your variables

Naming

Good names say what a value is for, using a consistent pattern, usually group-name or group-name-modifier:

:root {
  --color-primary: royalblue;
  --color-text: #1e293b;
  --color-surface: white;

  --space-1: 0.5rem;
  --space-2: 1rem;
  --space-3: 1.5rem;

  --radius-sm: 6px;
  --radius-md: 12px;

  --font-body: Arial, Helvetica, sans-serif;
  --shadow-card: 0 8px 20px rgba(0, 0, 0, 0.12);
}

A set of named values like this is called design tokens. They are the rulebook for a design.

Two layers: palette and meaning

For bigger projects, use two layers. The first is the raw palette, named by what the colour is. The second gives those colours jobs, named by what they are for. Only the second layer is used in components:

:root {
  /* layer 1: the palette */
  --blue-500: #3b82f6;
  --blue-700: #1d4ed8;
  --slate-900: #0f172a;
  --white: #ffffff;

  /* layer 2: the jobs */
  --color-primary: var(--blue-500);
  --color-primary-strong: var(--blue-700);
  --color-text: var(--slate-900);
  --color-surface: var(--white);
}

To theme the site, you swap only layer 2 (for example, point --color-surface at a dark colour). Every component keeps working untouched. You will use this in the dark mode lesson.

The spacing scale

A small set of spacing steps keeps a layout rhythmical. The three boxes in Part 4 show how they are used:

/* all three <div class="space-box"> */
.space-box {
  margin-bottom: var(--space-1);
  background-color: var(--color-primary);
  color: white;
}

.space-sm { padding: var(--space-1); }
.space-md { padding: var(--space-2); }
.space-lg { padding: var(--space-3); }

Instead of inventing a new padding every time, you pick the next step from the scale.

Changing variables with media queries and JavaScript

Variables can be redefined inside a media query, and everything that uses them updates automatically:

:root {
  --space-3: 1.5rem;
}

@media (min-width: 900px) {
  :root {
    --space-3: 2.5rem;     /* every component using --space-3 gets roomier */
  }
}

JavaScript can read and write them too, which is how colour pickers and theme switchers work:

// set a variable on the whole page
document.documentElement.style.setProperty('--color-primary', 'crimson');

You will use this in the next lesson to switch themes.

Typed variables with @property (a peek)

Normally a variable is just text until it is used, so the browser cannot animate it. The @property rule registers a variable with a type, a default and inheritance behaviour:

@property --angle {
  syntax: "<angle>";
  inherits: false;
  initial-value: 0deg;
}

Now the browser knows --angle is an angle, so it can animate it smoothly (for example, a spinning gradient). It is an advanced feature, and you will see it again in the animation lessons.

Gotchas

  • Variables cannot be used in media query conditions. @media (min-width: var(--bp)) does not work.
  • Variables cannot make up property names or selectors. var(--prop): red is invalid. They only stand in for values.
  • A number plus a unit is not concatenated. --size: 10; width: var(--size)px; is invalid. Store the unit in the variable (--size: 10px), or multiply: width: calc(var(--size) * 1px).
  • An invalid value makes the whole declaration invalid at use time, so the property falls back to its inherited or initial value (not to the previous rule). Check in DevTools if something looks unset.
  • Do not make circular references, such as --a: var(--b); --b: var(--a);.
  • Scoping surprises. A variable defined on .card is not available to .card’s parents or siblings. Declare shared variables on :root.

Putting it together

:root {
  --color-primary: royalblue;
  --color-surface: white;
  --color-text: #1e293b;
  --color-border: #e2e8f0;

  --space-1: 0.5rem;
  --space-2: 1rem;
  --space-3: 1.5rem;

  --radius: 12px;
}

body {
  font-family: Arial, Helvetica, sans-serif;
  color: var(--color-text);
}

.card {
  padding: var(--space-3);
  margin-bottom: var(--space-3);
  background-color: var(--color-surface);
  border: 1px solid var(--color-border);
  border-radius: var(--radius);
}

.btn {
  padding: var(--space-1) var(--space-2);
  border: 2px solid var(--color-primary);
  border-radius: var(--radius);
  font: inherit;
  cursor: pointer;
}

.btn-primary   { background-color: var(--color-primary); color: white; }
.btn-secondary { background-color: transparent; color: var(--color-primary); }

.theme-ocean {
  --color-primary: teal;
  --color-surface: #ecfeff;
  --color-border: #99f6e4;
  --color-text: #134e4a;

  padding: var(--space-3);
  margin-bottom: var(--space-3);
  background-color: #cffafe;
  color: var(--color-text);
  border-radius: var(--radius);
}

.badge {
  --badge-bg: var(--color-primary);
  display: inline-block;
  padding: 0.25rem 0.75rem;
  border-radius: 999px;
  background-color: var(--badge-bg);
  color: white;
  font-size: 0.875rem;
}

.badge-warning { --badge-bg: orange; }

.space-box { margin-bottom: var(--space-1); background-color: var(--color-primary); color: white; }
.space-sm  { padding: var(--space-1); }
.space-md  { padding: var(--space-2); }
.space-lg  { padding: var(--space-3); }

Common mistakes

  • Forgetting the two dashes when declaring (color-primary: blue), which creates an invalid property instead of a variable.
  • Forgetting var() when using it: background-color: --color-primary does nothing.
  • Declaring a variable on a specific element and then trying to use it elsewhere. Put shared ones on :root.
  • Naming variables after their value (--blue), so they make no sense when the value changes. Name them after their job (--color-primary).
  • Using variables for everything. Values that are used only once do not need a variable.
  • Writing var(--size)px. Units cannot be glued onto a variable. Store the unit in the variable or use calc().
  • Typos in names. A misspelt variable is silently undefined. Look for a crossed-out or empty value in DevTools.
  • Putting too many variables on a single component instead of exposing just a few meaningful knobs.

Practice

  1. Click Practice in Editor. Change --color-primary on :root to crimson. What changes, and what does not (hint: look at the ocean section)?
  2. Change --radius to 0, then to 30px. How many places update?
  3. Add a third theme section, .theme-sunset, that overrides the same variables with warm colours. Wrap another card in it. Do you have to touch the .card rule?
  4. Build the .badge with the --badge-bg knob, create the .badge-warning variant by setting only the variable, and set a third badge colour with an inline style.
  5. Create a hue-based button with hsl(var(--hue) 80% 45%). Change --hue and see the colour change.
  6. Add a fallback to a variable that you have not defined: padding: var(--missing, 2rem). What happens?
  7. Make --space-3 bigger inside a @media (min-width: 900px) block. Which elements get roomier?
  8. Open DevTools, select the card, and edit --color-primary live in the Styles pane of :root.

Recap

  • A CSS variable is declared with --name: value; and read with var(--name). Declare shared ones on :root.
  • Variables are inherited and follow the cascade. Redefine a variable on any element and everything inside it uses the new value. That is how themes work.
  • var(--x, fallback) supplies a fallback when the variable is missing.
  • Variables work inside calc(), clamp() and colour functions, so you can build palettes from a single --hue.
  • Make components with knobs (--badge-bg), and set variants by changing only the variable.
  • Organise design tokens with clear names, ideally in two layers: a palette and meaningful roles.
  • Variables can change in media queries and from JavaScript, but they cannot be used in media conditions or as property names.
  • Next you will use variables to build dark mode and a theme toggle.