CSS Custom Properties
Variables That Live in the Browser
Sass variables are dead after compilation -- they resolve to static values at build time and disappear. CSS custom properties? They're alive at runtime. They inherit through the DOM tree, respond to media queries, update with JavaScript, and cascade like any other property. This makes them fundamentally different from preprocessor variables and unlocks patterns that Sass literally can't touch.
Think of CSS custom properties as genetic traits. A parent element defines a trait (--color: blue). Every descendant inherits it automatically unless they override it with their own value. Just like genetics, you can trace any element's variable value by walking up its ancestor chain until you find the definition. The "gene" is the variable, the "expression" is the computed value at each element.
Defining and Using Custom Properties
:root {
--color-primary: oklch(0.55 0.19 240);
--color-text: #1a1a2e;
--spacing-md: 1rem;
--radius-lg: 12px;
--font-body: 'Inter', system-ui, sans-serif;
}
.card {
color: var(--color-text);
padding: var(--spacing-md);
border-radius: var(--radius-lg);
font-family: var(--font-body);
}
Fallback Values
.card {
/* Fallback if --card-bg is not defined */
background: var(--card-bg, white);
/* Nested fallback */
color: var(--card-text, var(--color-text, #333));
/* WRONG: Fallback is everything after the first comma */
background: var(--gradient, linear-gradient(red, blue));
/* This works! The fallback is "linear-gradient(red, blue)" */
}
The fallback in var() is everything after the first comma — including additional commas. var(--font, Arial, sans-serif) has a fallback of Arial, sans-serif, not just Arial. This is actually useful for font stacks but can be confusing for other values.
Inheritance and Scoping
Custom properties inherit just like color or font-size:
:root {
--text: black;
}
.dark-section {
--text: white; /* Override for this subtree */
}
/* Both use var(--text), but resolve to different values */
.dark-section p { color: var(--text); } /* white */
.light-section p { color: var(--text); } /* black (inherited from :root) */
Component-Level Scoping
/* The component defines its own custom property API */
.button {
--btn-bg: var(--color-primary);
--btn-text: white;
--btn-padding: 0.75rem 1.5rem;
--btn-radius: 8px;
background: var(--btn-bg);
color: var(--btn-text);
padding: var(--btn-padding);
border-radius: var(--btn-radius);
}
/* Variants override the component's variables */
.button--danger {
--btn-bg: var(--color-danger, #ef4444);
}
.button--ghost {
--btn-bg: transparent;
--btn-text: var(--color-primary);
}
/* External override from a parent context */
.dark-theme .button {
--btn-bg: oklch(0.35 0.15 240);
}
Runtime vs Compile-Time: CSS Variables vs Sass
This is a distinction worth really nailing down.
| Feature | CSS Custom Properties | Sass Variables |
|---|---|---|
| Resolved | Runtime (in browser) | Compile time (build) |
| Inherit | Yes — through DOM tree | No — lexical scope only |
| Media queries | Respond to @media | Cannot change at runtime |
| JavaScript | Read/write via CSSOM | Not accessible |
| Cascade | Yes — subject to specificity | No — last assignment wins |
| Calculations | Limited (no string concat) | Full programmatic logic |
/* This is impossible with Sass — runtime media response */
:root {
--grid-cols: 1;
}
@media (min-width: 640px) { :root { --grid-cols: 2; } }
@media (min-width: 1024px) { :root { --grid-cols: 3; } }
.grid {
display: grid;
grid-template-columns: repeat(var(--grid-cols), 1fr);
}
Theming System
This is where custom properties really shine. Let's build a theme switcher.
Light/Dark Theme with Custom Properties
:root {
/* Light theme (default) */
--bg: #ffffff;
--bg-surface: #f8f9fa;
--text-primary: #1a1a2e;
--text-secondary: #6b7280;
--border: #e5e7eb;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0f0f1a;
--bg-surface: #1a1a2e;
--text-primary: #e5e7eb;
--text-secondary: #9ca3af;
--border: #374151;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
}
}
/* Manual override via data attribute */
[data-theme="dark"] {
--bg: #0f0f1a;
--bg-surface: #1a1a2e;
--text-primary: #e5e7eb;
--text-secondary: #9ca3af;
--border: #374151;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
}
JavaScript Integration
// Read a custom property
const primary = getComputedStyle(document.documentElement)
.getPropertyValue('--color-primary');
// Set a custom property
document.documentElement.style.setProperty('--color-primary', '#ff6600');
// Set on a specific element (scoped)
card.style.setProperty('--card-bg', 'lightblue');
// Toggle theme
document.documentElement.setAttribute('data-theme',
currentTheme === 'light' ? 'dark' : 'light'
);
The @property rule — typed custom properties
By default, custom properties are strings with no type information. The @property rule lets you define type, initial value, and inheritance:
@property --rotation {
syntax: '<angle>';
inherits: false;
initial-value: 0deg;
}
/* Now the browser can ANIMATE this property */
.spinner {
transform: rotate(var(--rotation));
transition: --rotation 0.5s ease;
}
.spinner:hover {
--rotation: 360deg; /* Smooth rotation animation */
}Without @property, the browser can't animate custom properties — it doesn't know they represent angles, colors, or lengths. With @property, smooth transitions work because the browser understands the value type.
Production Scenario: Design Token System
This is how the pros structure it in production:
/* Layer 1: Primitive tokens (raw values) */
:root {
--gray-50: oklch(0.97 0 0);
--gray-100: oklch(0.93 0 0);
--gray-900: oklch(0.15 0 0);
--blue-500: oklch(0.55 0.19 240);
--red-500: oklch(0.55 0.20 25);
}
/* Layer 2: Semantic tokens (purpose-driven) */
:root {
--color-bg: var(--gray-50);
--color-text: var(--gray-900);
--color-primary: var(--blue-500);
--color-danger: var(--red-500);
--color-border: var(--gray-100);
}
/* Layer 3: Component tokens (scoped) */
.button {
--btn-bg: var(--color-primary);
--btn-text: white;
}
.input {
--input-border: var(--color-border);
--input-focus: var(--color-primary);
}
| What developers do | What they should do |
|---|---|
| Defining all variables on :root when they're component-specific Component-scoped variables are easier to maintain and don't pollute the global namespace | Scope component variables to the component selector. Use :root only for global tokens. |
| Expecting var(--undefined) to use the property's initial value var(--undefined) is 'guaranteed-invalid'. Use var(--prop, fallback) for safety. | An undefined custom property makes the entire value invalid, triggering the CSS property's inherited or initial value |
| Trying to concatenate strings with custom properties var(--prefix)value doesn't work. CSS custom properties are substituted as-is. | Custom properties can't do string operations. Use them for complete values, or combine with calc() for numbers. |
| Using Sass variables where runtime responsiveness is needed Sass variables are resolved at build time and can't change in the browser | Use CSS custom properties for anything that changes based on theme, viewport, or user interaction |
- 1CSS custom properties inherit through the DOM tree — scope them intentionally
- 2Use var(--prop, fallback) to handle undefined properties gracefully
- 3Custom properties are runtime values — use them for themes, responsive behavior, and JS integration
- 4Use @property to enable animation/transition of custom properties
- 5Build token systems in layers: primitive → semantic → component