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.
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
.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
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;
}
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
- Store any valid CSS value: colours, lengths, entire shorthand values, even
calc()expressions - Use
var()insidecalc():width: calc(var(--base) * 2) - Use
var()inside other functions:background: rgb(var(--r) var(--g) var(--b)) - Can't use them in property names:
var(--prop): valueis invalid - Can't use them in selector names
- Can't use them in media query conditions (but can use them inside property values within media queries)
- Can't concatenate them with strings to form a value:
var(--size)pxdoesn't work - usecalc(var(--size) * 1px)instead
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?
Question 2
What does var(--missing, 16px) resolve to when --missing is not declared anywhere in the DOM tree?
Question 3
You write: width: var(--size)px; where --size is 100. What happens?
Question 4
How do you correctly turn --size: 100 into a pixel value?