CSS Variables (Custom Properties)
Store reusable values in CSS, inherit them through the DOM, and update them at runtime with JavaScript.
1. Introduction
Before CSS custom properties (widely known as CSS variables), every value in a stylesheet was a raw, hard-coded number or color. If your brand color appeared 50 times across three files, changing it meant a find-and-replace across the whole project.
CSS custom properties solve this by letting you define a named token — --brand-color: royalblue — and reference it everywhere with var(--brand-color). Change the value in one place and every reference updates automatically.
But custom properties go further than "variables" in a typical programming language:
- They cascade and inherit through the DOM just like regular CSS properties.
- They can be overridden at any scope — globally on
:root, on a component, or even inline. - They can be read and written from JavaScript — enabling dynamic theming without toggling classes.
- They work inside
calc(), making dynamic math expressions possible.
2. Theory
2.1 Declaring a custom property
Custom property names must start with two dashes (--) and are case-sensitive.
/* Global scope — available everywhere */
:root {
--color-primary: royalblue;
--color-text: #333;
--spacing-md: 1rem;
--border-radius: 8px;
--font-heading: 'Segoe UI', system-ui, sans-serif;
}
/* Component scope — only available inside .card and its children */
.card {
--card-padding: 1.5rem;
--card-shadow: 0 2px 8px rgba(0,0,0,.1);
}
2.2 Using a custom property with var()
button {
background-color: var(--color-primary);
border-radius: var(--border-radius);
padding: var(--spacing-md) calc(var(--spacing-md) * 2);
}
h1, h2, h3 {
font-family: var(--font-heading);
color: var(--color-text);
}
2.3 Fallback values
var() accepts a second argument as a fallback, used if the variable is not defined or invalid:
/* If --accent-color is not defined, fall back to coral */
.badge {
background: var(--accent-color, coral);
}
/* Chained fallbacks */
.btn {
color: var(--btn-text-color, var(--color-primary, black));
}
2.4 Cascade and inheritance
Custom properties cascade exactly like regular properties. A value set on a child element overrides the parent's value — but only within that subtree.
:root { --text-color: #333; }
.sidebar { --text-color: white; }
/* All text inside .sidebar uses white; everywhere else uses #333 */
p { color: var(--text-color); }
2.5 Dark mode theming
Override a set of custom properties inside a prefers-color-scheme: dark media query — no need to duplicate CSS rules:
:root {
--bg: #ffffff;
--fg: #333333;
--surface: #f5f5f5;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #1a1a1a;
--fg: #eeeeee;
--surface: #2a2a2a;
}
}
body { background: var(--bg); color: var(--fg); }
.card { background: var(--surface); }
You can also toggle a data-theme="dark" attribute with JavaScript to give users a manual toggle:
[data-theme="dark"] {
--bg: #1a1a1a;
--fg: #eeeeee;
--surface: #2a2a2a;
}
2.6 Reading and writing custom properties with JavaScript
// Read a custom property value
const root = document.documentElement;
const primary = getComputedStyle(root).getPropertyValue('--color-primary').trim();
console.log(primary); // 'royalblue'
// Write / override a custom property at runtime
root.style.setProperty('--color-primary', '#e63946');
// Set on a specific element
document.querySelector('.card').style.setProperty('--card-padding', '2rem');
// Remove an override (falls back to :root value)
root.style.removeProperty('--color-primary');
2.7 Custom properties in calc()
:root {
--base-size: 4px;
}
.spacing-1 { padding: calc(var(--base-size) * 1); } /* 4px */
.spacing-2 { padding: calc(var(--base-size) * 2); } /* 8px */
.spacing-4 { padding: calc(var(--base-size) * 4); } /* 16px */
/* Dynamic column width */
:root { --cols: 3; }
.grid { grid-template-columns: repeat(var(--cols), 1fr); }
/* Change cols with JS: root.style.setProperty('--cols', '4'); */
2.8 Custom properties vs SASS variables
| Feature | CSS custom properties | SASS variables |
|---|---|---|
| Runtime access | Yes — readable/writable from JS | No — compiled away |
| Cascade / inheritance | Yes | No |
| Scope override | Per element / media query | Only at compile time |
| Browser support | All modern browsers | Requires build step |
| Nesting / loops | No | Yes |
In modern projects: use CSS custom properties for design tokens and dynamic theming; use SASS or PostCSS for loops, mixins, and complex pre-processing.
2.9 Invalid at computed value time
CSS variables are substituted at computed time. If the resolved value is invalid for the property, the browser uses the inherited value (for inheritable properties) or the initial value — not the next fallback in the cascade.
:root { --my-border: 2px red solid; }
/* This works */
.box { border: var(--my-border); }
:root { --gap: 1rem; }
/* Invalid — color cannot be a length. Browser falls to initial (transparent). */
.box { color: var(--gap); }
3. Real World Example
A design system might define all tokens as custom properties:
:root {
/* Colour palette */
--color-brand-50: #eff6ff;
--color-brand-500: #3b82f6;
--color-brand-900: #1e3a8a;
/* Semantic colours */
--color-primary: var(--color-brand-500);
--color-on-primary: #fff;
--color-surface: #fff;
--color-on-surface: #111;
/* Typography scale */
--font-size-sm: 0.875rem;
--font-size-base: 1rem;
--font-size-lg: 1.125rem;
--font-size-xl: 1.25rem;
--font-size-2xl: 1.5rem;
--font-size-4xl: 2.25rem;
/* Spacing scale */
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-4: 1rem;
--space-8: 2rem;
/* Motion */
--duration-fast: 150ms;
--duration-normal: 300ms;
--easing-default: ease;
}
Components use only the semantic tokens (--color-primary, --space-4, etc.). When the designer changes a palette colour, only the palette tokens need updating — all components update automatically.
4. Code Example
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="UTF-8">
<title>CSS Variables Demo — Theme Toggle</title>
<style>
/* ---- Design tokens ---- */
:root,
[data-theme="light"] {
--bg: #f8f9fa;
--surface: #ffffff;
--fg: #212529;
--fg-muted:#6c757d;
--primary: #0d6efd;
--primary-hover: #0b5ed7;
--border: #dee2e6;
--radius: 8px;
--shadow: 0 2px 8px rgba(0,0,0,.08);
--transition: 0.25s ease;
}
[data-theme="dark"] {
--bg: #0d1117;
--surface: #161b22;
--fg: #e6edf3;
--fg-muted:#8b949e;
--primary: #58a6ff;
--primary-hover: #79b8ff;
--border: #30363d;
--shadow: 0 2px 8px rgba(0,0,0,.4);
}
/* ---- Base styles using tokens ---- */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, sans-serif;
background: var(--bg);
color: var(--fg);
transition: background var(--transition), color var(--transition);
padding: 2rem;
}
.card-demo {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 1.5rem;
max-width: 400px;
margin-bottom: 1rem;
transition: background var(--transition), border-color var(--transition), box-shadow var(--transition);
}
.card-demo h2 { margin-bottom: 0.5rem; }
.card-demo p { color: var(--fg-muted); font-size: 0.9rem; }
.btn-primary {
display: inline-block;
margin-top: 1rem;
padding: 0.5rem 1.25rem;
background: var(--primary);
color: white;
border: none;
border-radius: var(--radius);
cursor: pointer;
font-size: 1rem;
transition: background var(--transition);
}
.btn-primary:hover { background: var(--primary-hover); }
/* ---- Theme toggle button ---- */
#theme-toggle {
position: fixed;
top: 1rem; right: 1rem;
padding: 0.5rem 1rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
cursor: pointer;
color: var(--fg);
transition: background var(--transition), color var(--transition);
}
/* ---- Dynamic column grid ---- */
.col-grid {
display: grid;
grid-template-columns: repeat(var(--cols, 2), 1fr);
gap: 1rem;
margin-top: 1rem;
}
.col-item {
background: var(--primary);
color: white;
border-radius: var(--radius);
padding: 1rem;
text-align: center;
}
</style>
</head>
<body>
<button id="theme-toggle">Toggle Dark Mode</button>
<div class="card-demo">
<h2>Card Component</h2>
<p>All colours, spacing, and shadows come from CSS custom properties.</p>
<button class="btn-primary">Primary Button</button>
</div>
<div class="card-demo">
<h2>Dynamic Grid Columns</h2>
<label>Columns: <input type="range" min="1" max="4" value="2" id="col-range"> <span id="col-val">2</span></label>
<div class="col-grid" id="col-grid">
<div class="col-item">A</div>
<div class="col-item">B</div>
<div class="col-item">C</div>
<div class="col-item">D</div>
</div>
</div>
<script>
// Theme toggle
const html = document.documentElement;
document.getElementById('theme-toggle').addEventListener('click', () => {
html.dataset.theme = html.dataset.theme === 'dark' ? 'light' : 'dark';
});
// Dynamic CSS variable from a range input
const range = document.getElementById('col-range');
const grid = document.getElementById('col-grid');
const label = document.getElementById('col-val');
range.addEventListener('input', () => {
grid.style.setProperty('--cols', range.value);
label.textContent = range.value;
});
</script>
</body>
</html>
5. Code Breakdown
Token definition (lines 5–16)
All tokens are declared on :root (and scoped to [data-theme="light"] for specificity safety). Semantic names like --fg (foreground), --surface, and --border mean the component CSS never references a raw color value. This makes theming trivially easy.
Dark mode override (lines 18–25)
Only the token values change — zero component CSS rules are duplicated. The data-theme="dark" attribute is toggled by JavaScript. The browser immediately recomputes var() references everywhere using the new values.
Smooth theme transition (line 38)
transition: background var(--transition), color var(--transition) — using a custom property for the transition duration means you can change the animation speed globally by updating --transition in one place.
Dynamic grid with JS (lines 71–76 and script)
grid-template-columns: repeat(var(--cols, 2), 1fr) uses a custom property with a fallback of 2. JavaScript reads the range input and calls style.setProperty('--cols', value) on the grid element — the grid reflows live without touching any CSS rule. This pattern is powerful for user-configurable UIs.
Theme toggle JS (lines 79–82)
Toggling html.dataset.theme between 'dark' and 'light' changes the data-theme attribute, which activates the matching CSS block. This is the cleanest way to implement a user-controlled theme toggle.
6. Common Mistakes
Mistake 1 — Using raw values instead of tokens in components
/* Bad — hard to theme or update globally */
.btn { background: royalblue; }
/* Good — single source of truth */
.btn { background: var(--color-primary); }
Mistake 2 — Setting custom properties on every element instead of :root
/* Bad — fragile, must repeat on every component */
.card { --brand: royalblue; }
.btn { --brand: royalblue; }
/* Good — inherit from :root */
:root { --brand: royalblue; }
.card, .btn { background: var(--brand); }
Mistake 3 — Forgetting the -- prefix
/* Bad — this is NOT a custom property */
:root { color-primary: royalblue; }
/* Good */
:root { --color-primary: royalblue; }
Mistake 4 — Expecting fallback to work like a cascade fallback
/* The fallback in var() only fires if the variable is UNDEFINED,
not if it is invalid for the property */
.box { width: var(--my-color, 100px); }
/* If --my-color: red, browser uses 'red' for width (invalid)
and falls to initial (auto), NOT to 100px */
Mistake 5 — Using JavaScript innerHTML to inject custom property names
Never construct property names from user input — e.g., style.setProperty('--' + userInput, value). Sanitise any user-provided strings to prevent CSS injection.
Mistake 6 — Overusing custom properties for one-off values
Custom properties add cognitive overhead. Only promote a value to a token if it appears in multiple places or needs to be changed dynamically. One-off values are fine as raw CSS literals.
7. Best Practices
- Define tokens on :root for global access via inheritance.
- Use semantic names (
--color-surface,--space-md) over raw names (--blue,--16px). - Group tokens by category — colours, spacing, typography, motion — with comments.
- Use scope overrides for component-level customisation rather than new token names.
- Provide fallbacks in
var()for optional/consumer-settable variables. - Transition colour tokens (add transition on body/root) for smooth dark-mode switches.
- Pair with data attributes for user-controlled themes instead of JS class toggling.
- Use in calc() to build a consistent spacing and sizing scale.
- Document your token system — a comment block at the top of your token file saves teammates hours.
- Don't replace SASS entirely if you already use it — custom properties complement SASS, covering runtime dynamics that SASS variables can't.
8. Practice Exercise
Build a themed component library.
Requirements
- Define a complete set of design tokens on
:root: at least 3 colors, 4 spacing steps, 3 font sizes, and a border-radius. - Build three components using only the tokens (no raw values): a button, a card, and a form input with label.
- Add a dark mode override block using
[data-theme="dark"]. - Add a JavaScript toggle button that switches
html.dataset.themebetween'light'and'dark'. - Animate the theme transition so background and text color fade smoothly (0.3s).
- Add a "compact mode" toggle: a second button that changes
--space-mdfrom1remto0.5remon:root. Observe all spacing update automatically.
Bonus
- Add a colour picker input (
<input type="color">) that writes the chosen colour to--color-primaryvia JavaScript. - Save the user's theme preference to
localStorageand restore it on page load.
9. Assignment
Refactor an existing page to use a CSS custom properties token system.
- Take any page you built in a previous module.
- Extract every repeated color, spacing, font size, and border-radius into named tokens on
:root. - Replace all raw values in component rules with
var(--token-name). - Create a complete dark-mode override using a
prefers-color-scheme: darkmedia query. - Add a manual dark-mode toggle button using the
data-themeattribute approach. - Store the user's choice in
localStorageand restore it when the page reloads. - Ensure all colour transitions animate smoothly (0.25s) when the theme changes.
Deliverable: One HTML file. Include a comment block at the top listing every token you defined and what it represents.
10. Interview Questions
- What are CSS custom properties and how do they differ from SASS variables?
CSS custom properties are native browser variables that cascade and inherit. They can be read and written at runtime with JavaScript. SASS variables are compiled away before the browser runs — they offer no runtime access and no cascade. - How do you declare and use a CSS custom property?
Declare with two dashes: --my-var: value. Use with var(--my-var). Define globally on :root for inheritance throughout the document. - What does the fallback value in var() do?
var(--x, fallback) uses the fallback only if --x is not defined (undefined, not invalid). If --x is defined but invalid for the property, the browser uses the inherited or initial value, not the fallback. - How would you implement a dark mode toggle with CSS custom properties?
Define light-mode tokens on :root. Override them in [data-theme="dark"] or @media (prefers-color-scheme: dark). Toggle the data-theme attribute with JavaScript, and optionally save the preference to localStorage. - Can custom properties be used inside calc()?
Yes. For example: padding: calc(var(--base-space) * 2). This lets you build consistent scale systems where changing one token updates all derived values. - How do you read and write a CSS custom property from JavaScript?
Read: getComputedStyle(element).getPropertyValue('--my-var'). Write: element.style.setProperty('--my-var', value). Remove: element.style.removeProperty('--my-var').
11. Additional Resources
- MDN — Using CSS custom properties (variables) — in-depth guide
- MDN — var() function reference
- CSS Tricks — A complete guide to custom properties
- web.dev — Building a color scheme — practical dark mode with custom properties
- Open Props — a popular open-source CSS custom properties design system
- Style Dictionary by Amazon — tool for managing design tokens across platforms