🎨 CSS Custom Properties and Variables
CSS Custom Properties — everyone calls them CSS variables — let you name a value once and reuse it everywhere. Unlike Sass variables, they're native to the browser, follow the cascade, and can change at runtime. That makes them the engine behind modern theming, dark mode, and design-token systems.
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Define and consume custom properties with the
--nameandvar()syntax, including fallbacks - Explain how variables scope and cascade, and override them per component or state
- Build a light/dark theme by swapping variables rather than rewriting components
- Read and update custom properties from JavaScript for live customization
- Structure variables as a layered design-token system
Estimated Time: 35–45 minutes • Difficulty: Intermediate
Hands-on: Build a themeable button set with a live color picker.
In This Lesson
What Are CSS Variables?
A CSS Custom Property is a value you store under a name and reference wherever you need it. Change the value in one place and every use updates. If you've used a preprocessor, that sounds like a Sass $variable — but custom properties are fundamentally more capable:
- Native — the browser understands them directly; no build step, no compilation.
- Dynamic — you can change a value at runtime with JavaScript and the page reflows instantly.
- Cascade-aware — they inherit and can be scoped to any element, not just the whole document.
- Live — a change propagates to every element currently using that variable.
💡 Analogy: A Sass variable is like a value baked into a printed document — set at print time, fixed forever. A CSS variable is like a value shown on a live dashboard: change the source and every display updates before your eyes.
--brand: #3b82f6;"] --> B["Use
color: var(--brand);"] B --> C["Update at runtime
el.style.setProperty()"] C --> B
Syntax: Define, Use, Fall Back
Defining a variable
A custom property name always starts with two hyphens. Define them on a selector; putting them on :root (which matches the <html> element) makes them global.
:root {
--primary-color: #3b82f6;
--text-color: #1e293b;
--spacing-unit: 8px;
--radius: 6px;
--font-heading: 'Inter', system-ui, sans-serif;
}
📖 Custom properties are case-sensitive
--primary-color and --Primary-Color are two different variables. This is unlike most of CSS, which is case-insensitive — an easy trap. Pick a convention (kebab-case is standard) and stick to it.
Using a variable
Read a variable with the var() function. You can nest it inside calc() for derived values.
.button {
background-color: var(--primary-color);
color: #fff;
padding: var(--spacing-unit) calc(var(--spacing-unit) * 2);
border-radius: var(--radius);
font-family: var(--font-heading);
}
Fallback values
var() takes an optional second argument used when the variable isn't defined. You can even nest a fallback inside a fallback.
.element {
/* If --custom-width is undefined, use 100% */
width: var(--custom-width, 100%);
/* Chained fallbacks */
color: var(--text-secondary, var(--text-primary, #000));
}
⚠️ A missing variable can wipe out the whole declaration
If you reference an undefined variable without a fallback, the property becomes invalid at computed-value time and falls back to its inherited or initial value — which is often not what you expected. Always provide a fallback for variables that might be missing, or make sure they're defined on :root.
Scope & the Cascade
The superpower of custom properties is that they obey the cascade. A variable defined on an element is available to that element and everything inside it, and a nested element can override it locally without touching the global value.
Component variants for free
Because a variable can be overridden locally, you can build variants by redefining just the value that changes — no repeated property lists.
.button {
--button-bg: var(--primary-color);
--button-color: #fff;
background-color: var(--button-bg);
color: var(--button-color);
padding: 8px 16px;
border: none;
border-radius: 4px;
}
/* Variants override only the token, not the whole rule */
.button--success { --button-bg: #22c55e; }
.button--warning { --button-bg: #f59e0b; --button-color: #1e293b; }
.button--danger { --button-bg: #ef4444; }
State-driven values
Toggle a variable on a state class and let a transition animate the result. This keeps the "what changed" (a single number) separate from the "how it looks" (the transform).
.accordion { --icon-rotation: 0deg; }
.accordion.is-open { --icon-rotation: 180deg; }
.accordion__icon {
transform: rotate(var(--icon-rotation));
transition: transform 0.3s ease;
}
Theming and Dark Mode
This is the killer app for custom properties. Define your colors as variables once, reference them everywhere, and switch the entire look by redefining the variables on a wrapper — no component CSS changes at all.
/* Light theme (default) */
:root {
--bg: #ffffff;
--text: #1e293b;
--surface: #f8fafc;
--border: #e2e8f0;
}
/* Dark theme — same names, new values */
:root[data-theme="dark"] {
--bg: #0f172a;
--text: #f1f5f9;
--surface: #1e293b;
--border: #334155;
}
/* Components reference the tokens and never mention a color directly */
body { background: var(--bg); color: var(--text); }
.card {
background: var(--surface);
border: 1px solid var(--border);
}
Flipping the theme is a one-line JavaScript change:
const toggle = document.querySelector('#theme-toggle');
toggle.addEventListener('click', () => {
const root = document.documentElement;
const next = root.dataset.theme === 'dark' ? 'light' : 'dark';
root.dataset.theme = next;
localStorage.setItem('theme', next); // remember the choice
});
✅ Respect the user's system preference
Start from what the operating system reports, then let the toggle override it. The prefers-color-scheme media query does the first half automatically:
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #0f172a;
--text: #f1f5f9;
}
}
This is exactly the pattern this very course site uses for its light/dark toggle.
Responsive tokens
Variables can also change at breakpoints, centralizing all your responsive scaling in one place instead of scattering media queries across every component.
:root {
--font-size-h1: 1.75rem;
--container-padding: 16px;
}
@media (min-width: 768px) {
:root {
--font-size-h1: 2.25rem;
--container-padding: 24px;
}
}
h1 { font-size: var(--font-size-h1); }
.container { padding: var(--container-padding); }
Controlling Variables with JavaScript
Because custom properties live in the DOM, JavaScript can both read and write them. This is what makes live user customization — color pickers, spacing sliders, "reader mode" — trivial to build.
Reading a value
const root = document.documentElement;
const brand = getComputedStyle(root)
.getPropertyValue('--primary-color')
.trim();
console.log(brand); // "#3b82f6"
Writing a value
// Globally, on :root
document.documentElement.style.setProperty('--primary-color', '#ec4899');
// Locally, on one element
const header = document.querySelector('.header');
header.style.setProperty('--header-bg', '#000');
A live color picker
<label for="brand">Brand color:</label>
<input type="color" id="brand" value="#3b82f6">
<script>
const picker = document.querySelector('#brand');
picker.addEventListener('input', (e) => {
document.documentElement.style.setProperty('--primary-color', e.target.value);
});
</script>
💡 Feed raw numbers into CSS for smooth interaction
You can update a numeric variable on every mouse move and let CSS do the rendering — a clean separation between logic (tracking input) and presentation (the transform).
const dot = document.querySelector('.cursor-dot');
document.addEventListener('mousemove', (e) => {
dot.style.setProperty('--x', `${e.clientX}px`);
dot.style.setProperty('--y', `${e.clientY}px`);
});
.cursor-dot {
transform: translate(var(--x, 0), var(--y, 0));
transition: transform 0.08s ease-out;
}
Design Tokens
On a real product you don't scatter one-off variables around — you build a design-token system: a small, layered vocabulary of named design decisions. The trick is two layers.
Layer 1 — base tokens (the raw palette)
:root {
/* Raw values — rarely change */
--blue-500: #3b82f6;
--green-500: #22c55e;
--gray-100: #f8fafc;
--gray-900: #0f172a;
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-4: 1rem;
--text-sm: 0.875rem;
--text-md: 1rem;
--text-xl: 1.25rem;
}
Layer 2 — semantic aliases (purpose-based)
Semantic tokens point at base tokens. Components use only the semantic layer, so re-skinning the product means editing a handful of aliases, not hunting through every component.
:root {
--color-primary: var(--blue-500);
--color-success: var(--green-500);
--color-text: var(--gray-900);
--color-bg: var(--gray-100);
--spacing-component: var(--space-4);
}
💡 Why two layers? Base tokens are the elements; semantic tokens are the compounds. When the brand color changes you update --color-primary once; when the whole palette shifts you touch only the base layer. Components — the top of the pyramid — never change.
Custom properties vs. Sass variables
| Feature | CSS Custom Properties | Sass / Less Variables |
|---|---|---|
| Runtime changes | Yes — via JavaScript, live | No — fixed at compile time |
| Cascade & scoping | Follows the cascade, scoped to elements | Lexical, file/module scope |
| Build step | None required | Requires compilation |
| Logic (loops, functions) | Limited to CSS features | Rich programming features |
They're complementary, not competing. Many teams use Sass for build-time logic and breakpoints, and custom properties for runtime theming. Reach for custom properties whenever a value must change at runtime or per component.
Hands-on Exercise
🏋️ Build a Themeable Button Set with a Live Color Picker
Objective: Combine variable-driven variants, a dark theme, and JavaScript control into one small demo.
Instructions
- Define a base
.btnthat reads its colors from--btn-bgand--btn-fgtokens. - Create
.btn--successand.btn--dangervariants that override only--btn-bg. - Add a
[data-theme="dark"]block that redefines the page background and text tokens. - Wire an
<input type="color">so changing it updates--primary-colorlive on:root.
💡 Hint
Make .btn's default --btn-bg reference var(--primary-color). Then the color picker, by changing --primary-color, automatically restyles every default button — but not the --success / --danger ones, because they override --btn-bg. That cascade behavior is the lesson.
✅ Solution
<label for="brand">Brand color:</label>
<input type="color" id="brand" value="#3b82f6">
<button id="theme" type="button">Toggle theme</button>
<div class="demo">
<button class="btn">Default</button>
<button class="btn btn--success">Success</button>
<button class="btn btn--danger">Danger</button>
</div>
:root {
--primary-color: #3b82f6;
--page-bg: #ffffff;
--page-fg: #1e293b;
}
:root[data-theme="dark"] {
--page-bg: #0f172a;
--page-fg: #f1f5f9;
}
body { background: var(--page-bg); color: var(--page-fg); }
.btn {
--btn-bg: var(--primary-color);
--btn-fg: #fff;
background: var(--btn-bg);
color: var(--btn-fg);
padding: 8px 16px;
border: none;
border-radius: 6px;
cursor: pointer;
}
.btn--success { --btn-bg: #22c55e; }
.btn--danger { --btn-bg: #ef4444; }
const root = document.documentElement;
document.querySelector('#brand').addEventListener('input', (e) => {
root.style.setProperty('--primary-color', e.target.value);
});
document.querySelector('#theme').addEventListener('click', () => {
root.dataset.theme = root.dataset.theme === 'dark' ? 'light' : 'dark';
});
Move the picker: the "Default" button re-tints instantly, while Success and Danger hold their overrides. Toggle the theme: the page background flips without touching a single button rule.
Best Practices
✅ Do
- Use descriptive, consistent names (
--color-primary, not--c1). - Split base tokens from semantic aliases so re-skinning is a small edit.
- Provide fallbacks for any variable that might be undefined.
- Group and comment related tokens; keep global tokens in one place.
⚠️ Don't
- Don't build deep dependency chains (a token referencing a token referencing a token) — they're hard to debug.
- Don't update variables on every animation frame if a CSS transition can do the job — batch writes.
- Don't forget custom properties are case-sensitive.
- Don't over-scope: define a variable at the broadest level it's genuinely shared, no higher.
Summary & Quiz
🎉 Key Takeaways
- Define with
--name: value, read withvar(--name, fallback). - Variables follow the cascade — override them per component or per state, not just globally.
- Theming = swap variable values on a wrapper; components never mention a raw color.
- JavaScript reads with
getComputedStyle().getPropertyValue()and writes withsetProperty(). - A two-layer token system (base + semantic) keeps large design systems maintainable.
🎯 Quick Quiz
Question 1: What is the biggest advantage of CSS custom properties over Sass variables?
Question 2: Which line correctly uses a variable with a fallback of 100%?
Question 3: To switch a whole site to dark mode using custom properties, you should…
📚 Further Reading
- MDN — Using CSS custom properties
- CSS-Tricks — A Complete Guide to Custom Properties
- W3C — CSS Custom Properties Level 1
🚀 What's Next?
You now have a dynamic, cascade-aware way to store design decisions. Next we'll look at how build tools scope entire stylesheets to a component with CSS Modules and Component-Based Styling — and how tokens like these plug into that world.
🎉 Themeable and dynamic!
You can now build a light/dark, user-customizable UI from a single set of tokens.