← Back to Index

Lesson 1

CSS Custom Properties from the Ground Up

Custom properties (often called "CSS variables") let you store a value once and reuse it anywhere in your stylesheet. They're native to CSS - no preprocessor needed - and they behave differently from Sass/Less variables in important ways.

Declaring and using

A custom property is any property name that starts with --. You declare it inside a rule block, then consume it with var():

/* Declare */
.card {
  --card-padding: 1.5rem;
  --card-radius: 8px;
}

/* Use */
.card {
  padding: var(--card-padding);
  border-radius: var(--card-radius);
}

That's the whole syntax. Two hyphens to declare, var() to read.

Scope: they follow the DOM, not the stylesheet

This is the big mental shift if you're coming from Sass. A Sass variable is resolved at compile time based on where it appears in your source file. A custom property is resolved at runtime based on where the element sits in the DOM tree.

Custom properties are scoped to the element they're declared on, and they inherit down through the DOM - just like color or font-size.
:root {
  --brand: blue;    /* available everywhere (html element) */
}

.sidebar {
  --brand: green;   /* overrides for .sidebar and its children */
}

.button {
  background: var(--brand);
  /* A .button inside .sidebar gets green.
     A .button elsewhere gets blue. */
}

Live demo: scoping in action

Same class, different --demo-color values

Parent: --demo-color is #3a86ff (blue)
Child inherits --demo-color - blue background
Parent: --demo-color is #e76f51 (orange)
Child inherits --demo-color - orange background
.scope-demo {
  --demo-color: #3a86ff;
  border: 2px solid var(--demo-color);
}

.scope-demo .child {
  background: var(--demo-color);
  color: white;
}

.scope-demo.override {
  --demo-color: #e76f51;
}

Both .child elements use the same CSS rule (background: var(--demo-color)), but they resolve to different colours because the custom property was set differently on their parents. This is impossible with Sass variables.

Why :root?

You'll see custom properties declared on :root constantly. That's just the <html> element - it's the highest point in the DOM tree, so anything declared there inherits everywhere. Think of it as "global scope":

:root {
  --spacing-sm: 0.5rem;
  --spacing-md: 1rem;
  --spacing-lg: 2rem;
  --color-primary: #3a86ff;
  --color-text: #1a1a2e;
}

But you're not limited to :root. Declaring on a more specific selector narrows the scope - useful for component-level tokens.

Fallbacks

var() takes an optional second argument: a fallback value used when the property isn't defined:

.element {
  /* If --custom-bg isn't set on this element or any ancestor,
     use #e8f8f5 instead */
  background: var(--custom-bg, #e8f8f5);

  /* Fallbacks can be other var() calls */
  color: var(--theme-text, var(--color-text, black));
}

This box uses var(--custom-bg, #e8f8f5) - since --custom-bg isn't set, the fallback applies

This element is using the fallback background colour.
Important subtlety: the fallback is only used when the property is not defined at all (no declaration reaches this element). If the property IS defined but has an invalid value for the context, the fallback does not kick in - instead you get the property's inherited or initial value. This catches people out.

Custom properties vs Sass variables

Sass variable

$color: blue;

.a { color: $color; }

$color: red;

.b { color: $color; }

.a is blue, .b is red. Resolved at compile time, top to bottom in the file.

Custom property

:root { --color: blue; }

.a { color: var(--color); }

.dark { --color: red; }

.b { color: var(--color); }

.b inside .dark is red. .b outside .dark is blue. Resolved at runtime, based on DOM position.

This runtime resolution is what makes custom properties powerful for theming, responsive design, and component APIs.

Live demo: theming with custom properties

Click to toggle dark theme - same HTML, different property values

Card component

This card uses --card-bg, --card-text, and --card-border. The .dark class just redefines those three properties - no other CSS changes.

.theme-demo {
  --card-bg: #fff;
  --card-text: #1a1a2e;
  --card-border: #e0e0e0;
  background: var(--card-bg);
  color: var(--card-text);
  border: 1px solid var(--card-border);
  transition: background 0.3s, color 0.3s;
}

.theme-demo.dark {
  --card-bg: #1a1a2e;
  --card-text: #f0f0f0;
  --card-border: #333;
}
Ad

They're live and reactive

Because custom properties are resolved at runtime, you can change them with JavaScript and the page updates instantly - no reflow of the stylesheet needed:

// Change a property on the root element
document.documentElement.style.setProperty('--color-primary', '#e76f51');

// Change it on a specific element
myCard.style.setProperty('--card-padding', '2rem');

This is the foundation of many CSS-in-JS alternatives: define your design tokens as custom properties, then manipulate them from JS when state changes.

Naming conventions

There's no enforced naming scheme, but common patterns:

/* Global design tokens */
--color-primary: #3a86ff;
--spacing-md: 1rem;
--font-body: system-ui, sans-serif;

/* Component-scoped (prefixed with component name) */
--card-padding: 1.5rem;
--card-radius: 8px;
--btn-bg: var(--color-primary);

The double-hyphen prefix (--) is required by the spec. Everything after that is yours. Case-sensitive: --Color and --color are different properties.

What you can and can't do

Quiz

Question 1

A custom property --brand is set on :root as "blue" and on .sidebar as "green". A .button inside .sidebar uses var(--brand). What colour is it?

blue - :root always wins
green - closer ancestor wins
invalid - conflicting declarations
transparent - no valid value

Question 2

What does var(--missing, 16px) resolve to when --missing is not declared anywhere in the DOM tree?

nothing - the declaration is skipped
0px - numeric properties default to zero
16px - the fallback value is used
inherit - falls back to parent value

Question 3

You write: width: var(--size)px; where --size is 100. What happens?

Works fine - width becomes 100px
Invalid - can't concatenate like this
Becomes "100 px" with a space in it
Silently ignored, uses inherited width

Question 4

How do you correctly turn --size: 100 into a pixel value?

width: var(--size) + "px";
width: #{var(--size)}px;
width: calc(var(--size) * 1px);
width: var(--size, px);