CSS Custom Properties: The Complete Guide for 2026
CSS Custom Properties — often called CSS Variables — have fundamentally changed how we write and maintain stylesheets. They give you a native, runtime variable system that participates in the cascade, inherits through the DOM, responds to media queries and state changes, and can be manipulated with JavaScript. No build step required. No preprocessor needed. Just CSS, doing what CSS was always supposed to do: adapt to context.
This guide covers everything you need to build production-ready systems with CSS custom properties: the declaration and usage syntax, scope and inheritance mechanics, fallback values, design token architectures, theming and dark mode systems, responsive design patterns, animation with @property, JavaScript integration, performance considerations, and how custom properties compare to preprocessor variables. Every section includes code you can copy and adapt immediately.
What Are CSS Custom Properties and Why They Matter
A CSS custom property is a property defined by the author (you) that stores a value and can be referenced throughout a stylesheet. Custom properties are declared with a name starting with two dashes (--) and consumed using the var() function.
:root {
--brand-color: #3b82f6;
}
.button {
background: var(--brand-color);
}
That looks simple, and it is. But the implications are significant. Before custom properties existed, changing a color used in 50 places meant a find-and-replace operation (or a preprocessor variable that compiled to static output). With custom properties, you change one value at runtime and every element referencing it updates automatically. No recompilation. No JavaScript. No React re-render.
Custom properties matter because they solve three problems that preprocessor variables cannot:
- Runtime dynamism — values can change in response to user interaction, media queries, container queries, or JavaScript, and the UI updates instantly
- DOM-scoped cascading — a custom property set on a parent element flows to all descendants, allowing component-level theming without global state
- Interoperability — custom properties work in any CSS context: stylesheets, inline styles,
@keyframes,calc(), and even inside othervar()calls
Syntax: Declaring with -- and Using with var()
Declaration
Custom properties are declared inside any CSS rule block. The name must begin with -- followed by one or more characters. Names are case-sensitive.
/* Declaring custom properties */
:root {
--color-primary: #3b82f6;
--font-size-base: 1rem;
--spacing-unit: 0.5rem;
--border-radius: 8px;
--shadow-card: 0 4px 6px rgba(0, 0, 0, 0.1);
--transition-default: 200ms ease-in-out;
}
/* Names are case-sensitive: these are two different properties */
--myColor: red;
--mycolor: blue;
The value of a custom property can be virtually anything valid in CSS: colors, lengths, strings, numbers, gradients, entire shorthand values, even multiple values separated by commas.
:root {
/* Single values */
--gap: 1.5rem;
--font: 'Inter', sans-serif;
/* Complex values */
--gradient: linear-gradient(135deg, #667eea, #764ba2);
--grid-cols: repeat(3, 1fr);
--animation: slide-in 300ms ease-out forwards;
/* Partial values (used inside calc or combined properties) */
--hue: 220;
--alpha: 0.8;
}
Usage with var()
The var() function retrieves the current computed value of a custom property. It can be used anywhere a CSS value is expected.
.card {
background: var(--color-surface);
border-radius: var(--border-radius);
padding: var(--spacing-unit);
box-shadow: var(--shadow-card);
font-family: var(--font);
transition: transform var(--transition-default);
}
/* Inside calc() */
.sidebar {
width: calc(var(--spacing-unit) * 40);
}
/* Building composite values */
.highlight {
color: hsl(var(--hue), 80%, 60%);
background: hsla(var(--hue), 80%, 60%, var(--alpha));
}
One critical limitation: var() cannot be used in property names, selectors, or media query conditions. It can only appear where a property value is expected.
/* INVALID: var() in property name */
var(--prop-name): 10px;
/* INVALID: var() in media query condition */
@media (min-width: var(--breakpoint)) { }
/* VALID: var() in the value inside a media query block */
@media (min-width: 768px) {
:root { --columns: 3; }
}
Scope and Inheritance: Global vs Local
Custom properties follow the same cascading and inheritance rules as any other CSS property. This is the key feature that separates them from preprocessor variables.
Global Scope with :root
Properties declared on :root (the <html> element) are available to every element in the document because all elements inherit from the root.
:root {
--color-text: #e4e4e7;
--color-bg: #0f0f23;
}
/* Both of these inherit --color-text from :root */
.header { color: var(--color-text); }
.footer { color: var(--color-text); }
Local Scope with Element Selectors
When you declare a custom property on a specific element, it overrides the inherited value for that element and all its descendants.
:root {
--accent: #3b82f6; /* blue globally */
}
.sidebar {
--accent: #10b981; /* green in sidebar */
}
.danger-zone {
--accent: #ef4444; /* red in danger zone */
}
/* This button takes whatever --accent is in its context */
.btn-accent {
background: var(--accent);
color: white;
}
In this example, a .btn-accent inside .sidebar is green, one inside .danger-zone is red, and one anywhere else is blue. The button component does not need to know about its context. It just reads --accent from whatever ancestor defines it. This is the foundation of contextual theming.
Inheritance Through the DOM Tree
Custom properties inherit exactly like color or font-family. If an element does not define a custom property, it inherits the value from its parent, which inherits from its parent, all the way up to :root.
<div class="theme-dark"> <!-- --text: #fff -->
<div class="card"> <!-- inherits --text: #fff -->
<p>Dark card text</p> <!-- inherits --text: #fff -->
</div>
<div class="card theme-light"> <!-- --text: #111 (overridden) -->
<p>Light card text</p> <!-- inherits --text: #111 -->
</div>
</div>
.theme-dark { --text: #ffffff; --bg: #1a1a2e; }
.theme-light { --text: #111827; --bg: #ffffff; }
.card {
color: var(--text);
background: var(--bg);
}
Fallback Values
The var() function accepts an optional second argument: a fallback value used when the referenced property is not defined.
.element {
/* If --accent is not defined, use #3b82f6 */
color: var(--accent, #3b82f6);
/* If --gap is not defined, use 1rem */
padding: var(--gap, 1rem);
/* Fallback can be another var() — nested fallbacks */
background: var(--bg-override, var(--bg-default, #1a1a2e));
}
Fallback values are resolved at computed-value time. If the referenced property exists but resolves to an invalid value for the property being set, the fallback is not used. Instead, the declaration becomes "invalid at computed value time" (IACVT), and the property resets to its inherited or initial value. This is a common source of confusion.
:root {
--width: blue; /* a color, not a length */
}
.box {
/* --width exists but "blue" is invalid for width.
The fallback 100px is NOT used.
width becomes "unset" (auto for block elements). */
width: var(--width, 100px);
}
The rule is: the fallback only applies when the property is not defined at all (or is a guaranteed-invalid value from a failed @property registration). If the property is defined but holds a value incompatible with the context, you get IACVT behavior, not the fallback.
Dynamic Updates with JavaScript
One of the most powerful aspects of CSS custom properties is that JavaScript can read and write them at runtime, and the UI updates immediately without a page reload or framework re-render.
Reading Custom Properties
// Read a custom property from an element
const root = document.documentElement;
const primaryColor = getComputedStyle(root)
.getPropertyValue('--color-primary')
.trim();
console.log(primaryColor); // "#3b82f6"
Writing Custom Properties
// Set a custom property on :root (affects entire document)
document.documentElement.style.setProperty('--color-primary', '#ef4444');
// Set on a specific element (scoped override)
const sidebar = document.querySelector('.sidebar');
sidebar.style.setProperty('--accent', '#10b981');
// Remove a custom property override
sidebar.style.removeProperty('--accent');
Practical Example: User-Controlled Theme
// Range slider controlling font size
const slider = document.querySelector('#font-size-slider');
slider.addEventListener('input', (e) => {
document.documentElement.style.setProperty(
'--font-size-base',
e.target.value + 'px'
);
});
// Color picker controlling accent color
const picker = document.querySelector('#accent-picker');
picker.addEventListener('input', (e) => {
document.documentElement.style.setProperty(
'--color-primary',
e.target.value
);
});
Tracking Mouse Position
document.addEventListener('mousemove', (e) => {
document.documentElement.style.setProperty('--mouse-x', e.clientX + 'px');
document.documentElement.style.setProperty('--mouse-y', e.clientY + 'px');
});
.spotlight {
background: radial-gradient(
circle at var(--mouse-x) var(--mouse-y),
rgba(59, 130, 246, 0.15) 0%,
transparent 50%
);
}
This pattern is extremely efficient because you are only setting two properties on the root element. The browser handles all the rendering updates through the normal style computation pipeline — no DOM manipulation, no layout thrashing.
Design Tokens and Theming Systems
Design tokens are the atomic building blocks of a design system: colors, spacing, typography scales, shadows, and radii. CSS custom properties are the natural implementation mechanism for design tokens in the browser.
Three-Layer Token Architecture
Production design systems typically use three layers of custom properties:
/* Layer 1: Primitive tokens (raw values) */
:root {
--blue-50: #eff6ff;
--blue-100: #dbeafe;
--blue-500: #3b82f6;
--blue-600: #2563eb;
--blue-900: #1e3a8a;
--gray-50: #f9fafb;
--gray-100: #f3f4f6;
--gray-700: #374151;
--gray-800: #1f2937;
--gray-900: #111827;
--spacing-1: 0.25rem;
--spacing-2: 0.5rem;
--spacing-3: 0.75rem;
--spacing-4: 1rem;
--spacing-6: 1.5rem;
--spacing-8: 2rem;
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-full: 9999px;
}
/* Layer 2: Semantic tokens (purpose-driven) */
:root {
--color-primary: var(--blue-500);
--color-primary-hover: var(--blue-600);
--color-bg: var(--gray-900);
--color-surface: var(--gray-800);
--color-text: var(--gray-50);
--color-text-muted: var(--gray-700);
--color-border: var(--gray-700);
--space-inline: var(--spacing-4);
--space-stack: var(--spacing-6);
--space-section: var(--spacing-8);
--radius-card: var(--radius-md);
--radius-button: var(--radius-md);
--radius-input: var(--radius-sm);
}
/* Layer 3: Component tokens (scoped to UI elements) */
.btn {
--btn-bg: var(--color-primary);
--btn-bg-hover: var(--color-primary-hover);
--btn-text: #ffffff;
--btn-padding: var(--spacing-2) var(--spacing-4);
--btn-radius: var(--radius-button);
background: var(--btn-bg);
color: var(--btn-text);
padding: var(--btn-padding);
border-radius: var(--btn-radius);
}
.btn:hover {
background: var(--btn-bg-hover);
}
This layered approach means you can swap the entire visual theme by overriding only the semantic layer. The primitive values stay the same (blue is still blue), but what "primary" means changes depending on context.
Dark Mode Implementation with Custom Properties
Dark mode is the most common theming use case for CSS custom properties. There are three implementation strategies, and the best approach combines two of them.
Strategy 1: prefers-color-scheme Media Query
/* Light theme (default) */
:root {
--bg-primary: #ffffff;
--bg-secondary: #f3f4f6;
--text-primary: #111827;
--text-muted: #6b7280;
--border-color: #e5e7eb;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
/* Dark theme (automatic via OS preference) */
@media (prefers-color-scheme: dark) {
:root {
--bg-primary: #0f0f23;
--bg-secondary: #1a1a2e;
--text-primary: #e4e4e7;
--text-muted: #9ca3af;
--border-color: rgba(255, 255, 255, 0.08);
--shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
}
}
Strategy 2: Class or Data Attribute Toggle
/* Light theme */
:root,
[data-theme="light"] {
--bg-primary: #ffffff;
--bg-secondary: #f3f4f6;
--text-primary: #111827;
--text-muted: #6b7280;
}
/* Dark theme */
[data-theme="dark"] {
--bg-primary: #0f0f23;
--bg-secondary: #1a1a2e;
--text-primary: #e4e4e7;
--text-muted: #9ca3af;
}
// Toggle dark mode
const toggle = document.querySelector('#theme-toggle');
toggle.addEventListener('click', () => {
const current = document.documentElement.getAttribute('data-theme');
const next = current === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', next);
localStorage.setItem('theme', next);
});
// Restore preference on load
const saved = localStorage.getItem('theme');
if (saved) {
document.documentElement.setAttribute('data-theme', saved);
}
Strategy 3: Combined (Recommended)
The best approach respects the OS preference by default but lets users override it manually.
/* Default: respect OS preference */
:root {
--bg-primary: #ffffff;
--text-primary: #111827;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg-primary: #0f0f23;
--text-primary: #e4e4e7;
}
}
/* Manual override: explicit dark */
[data-theme="dark"] {
--bg-primary: #0f0f23;
--text-primary: #e4e4e7;
}
/* Manual override: explicit light */
[data-theme="light"] {
--bg-primary: #ffffff;
--text-primary: #111827;
}
With this setup, users who never touch the toggle get automatic dark/light based on their OS. Users who click the toggle get their explicit choice, which overrides the OS preference.
Responsive Design with Custom Properties
Custom properties excel at responsive design because you can redefine values at different breakpoints while keeping all usage sites unchanged.
Responsive Spacing and Typography
:root {
--font-size-h1: 1.75rem;
--font-size-h2: 1.35rem;
--font-size-body: 0.95rem;
--section-padding: 1.5rem;
--grid-columns: 1;
}
@media (min-width: 768px) {
:root {
--font-size-h1: 2.25rem;
--font-size-h2: 1.65rem;
--font-size-body: 1rem;
--section-padding: 3rem;
--grid-columns: 2;
}
}
@media (min-width: 1200px) {
:root {
--font-size-h1: 3rem;
--font-size-h2: 2rem;
--font-size-body: 1.05rem;
--section-padding: 4rem;
--grid-columns: 3;
}
}
/* Usage is breakpoint-agnostic */
h1 { font-size: var(--font-size-h1); }
h2 { font-size: var(--font-size-h2); }
p { font-size: var(--font-size-body); }
.content-grid {
display: grid;
grid-template-columns: repeat(var(--grid-columns), 1fr);
gap: var(--section-padding);
padding: var(--section-padding);
}
This pattern centralizes all responsive changes into the :root media query blocks. Your component CSS never needs its own media queries — it just reads the custom properties and adapts.
Container Queries with Custom Properties
.widget-container {
container-type: inline-size;
}
.widget {
--widget-direction: column;
--widget-gap: 0.75rem;
--widget-img-width: 100%;
}
@container (min-width: 400px) {
.widget {
--widget-direction: row;
--widget-gap: 1.5rem;
--widget-img-width: 40%;
}
}
.widget {
display: flex;
flex-direction: var(--widget-direction);
gap: var(--widget-gap);
}
.widget img {
width: var(--widget-img-width);
}
Animation with Custom Properties
By default, CSS cannot transition or animate custom properties because the browser treats their values as opaque strings. The @property at-rule changes this by registering a custom property with a specific type.
The @property At-Rule
@property --hue {
syntax: '<number>';
inherits: false;
initial-value: 220;
}
@property --progress {
syntax: '<percentage>';
inherits: false;
initial-value: 0%;
}
@property --gradient-angle {
syntax: '<angle>';
inherits: false;
initial-value: 0deg;
}
The syntax descriptor tells the browser what type of value the property holds. Supported types include <number>, <integer>, <length>, <percentage>, <color>, <angle>, <length-percentage>, <time>, and more. Once the browser knows the type, it can interpolate between values during transitions and animations.
Animated Gradient Background
@property --gradient-angle {
syntax: '<angle>';
inherits: false;
initial-value: 0deg;
}
.animated-gradient {
background: linear-gradient(
var(--gradient-angle),
#667eea,
#764ba2
);
animation: rotate-gradient 3s linear infinite;
}
@keyframes rotate-gradient {
to { --gradient-angle: 360deg; }
}
Without @property, this animation would not work. The browser would see --gradient-angle as a string and snap between "0deg" and "360deg" instead of smoothly interpolating.
Animated Color Shift
@property --hue {
syntax: '<number>';
inherits: false;
initial-value: 220;
}
.color-shift {
background: hsl(var(--hue), 70%, 50%);
transition: --hue 600ms ease;
}
.color-shift:hover {
--hue: 340;
}
Progress Bar Animation
@property --progress {
syntax: '<percentage>';
inherits: false;
initial-value: 0%;
}
.progress-bar {
background: linear-gradient(
90deg,
#3b82f6 var(--progress),
#1f2937 var(--progress)
);
transition: --progress 1s ease-out;
height: 8px;
border-radius: 4px;
}
/* Set via JavaScript or class toggle */
.progress-bar.loaded { --progress: 100%; }
@property Rule for Typed Custom Properties
Beyond enabling animation, @property provides type safety, initial values, and controlled inheritance for custom properties.
Type Safety
@property --spacing {
syntax: '<length>';
inherits: true;
initial-value: 1rem;
}
/* If someone sets --spacing to an invalid value like "blue",
the browser uses the initial-value (1rem) instead of
triggering IACVT behavior */
.box {
padding: var(--spacing);
}
Controlling Inheritance
@property --card-bg {
syntax: '<color>';
inherits: false; /* Does NOT inherit from parent */
initial-value: #1a1a2e;
}
/* Each .card gets #1a1a2e unless it explicitly sets --card-bg.
Nested cards do not inherit their parent card's --card-bg. */
.card {
background: var(--card-bg);
}
Supported @property Syntax Types
The syntax descriptor supports these types: <number>, <integer>, <length>, <percentage>, <length-percentage>, <color>, <angle>, <time>, <resolution>, <image>, and <transform-function>. You can combine types with | for alternatives, use + for space-separated lists, and # for comma-separated lists:
@property --size { syntax: '<length> | auto'; inherits: false; initial-value: auto; }
@property --sizes { syntax: '<length>+'; inherits: false; initial-value: 0px; }
@property --colors { syntax: '<color>#'; inherits: false; initial-value: black; }
Real-World Patterns and Best Practices
Component API Pattern
Expose custom properties as the public API of a component. Consumers customize behavior by overriding properties, not by writing new CSS rules.
/* Card component with configurable properties */
.card {
--card-padding: 1.5rem;
--card-radius: 8px;
--card-bg: #1a1a2e;
--card-border: rgba(255, 255, 255, 0.08);
--card-shadow: 0 4px 6px rgba(0, 0, 0, 0.15);
padding: var(--card-padding);
border-radius: var(--card-radius);
background: var(--card-bg);
border: 1px solid var(--card-border);
box-shadow: var(--card-shadow);
}
/* Usage: customize without touching .card internals */
.featured-card {
--card-bg: #1e293b;
--card-border: rgba(59, 130, 246, 0.3);
--card-shadow: 0 8px 24px rgba(59, 130, 246, 0.15);
}
.compact-card {
--card-padding: 0.75rem;
--card-radius: 4px;
}
Spacing Scale System
:root {
--space-unit: 0.25rem; /* 4px base */
--space-1: calc(var(--space-unit) * 1); /* 4px */
--space-2: calc(var(--space-unit) * 2); /* 8px */
--space-3: calc(var(--space-unit) * 3); /* 12px */
--space-4: calc(var(--space-unit) * 4); /* 16px */
--space-6: calc(var(--space-unit) * 6); /* 24px */
--space-8: calc(var(--space-unit) * 8); /* 32px */
--space-12: calc(var(--space-unit) * 12); /* 48px */
--space-16: calc(var(--space-unit) * 16); /* 64px */
}
/* Change the entire scale by adjusting one value */
@media (min-width: 1400px) {
:root { --space-unit: 0.3rem; } /* Slightly larger on wide screens */
}
Fluid Typography with Custom Properties and clamp()
:root {
--fluid-min-width: 320;
--fluid-max-width: 1200;
--font-size-sm: clamp(0.8rem, 0.75rem + 0.25vw, 0.9rem);
--font-size-base: clamp(1rem, 0.9rem + 0.5vw, 1.125rem);
--font-size-lg: clamp(1.25rem, 1rem + 1vw, 1.5rem);
--font-size-xl: clamp(1.5rem, 1rem + 2vw, 2.25rem);
--font-size-2xl: clamp(2rem, 1.25rem + 3vw, 3.5rem);
}
Consistent Border and Focus Styles
:root {
--focus-ring-color: rgba(59, 130, 246, 0.5);
--focus-ring-offset: 2px;
--focus-ring-width: 3px;
}
/* Reusable focus style */
:focus-visible {
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: var(--focus-ring-offset);
}
/* High contrast mode override */
@media (forced-colors: active) {
:root {
--focus-ring-color: Highlight;
}
}
Color Palette from a Single Hue
:root {
--hue: 220;
--color-50: hsl(var(--hue), 90%, 96%);
--color-100: hsl(var(--hue), 85%, 90%);
--color-200: hsl(var(--hue), 80%, 80%);
--color-300: hsl(var(--hue), 75%, 70%);
--color-400: hsl(var(--hue), 70%, 60%);
--color-500: hsl(var(--hue), 65%, 50%);
--color-600: hsl(var(--hue), 70%, 40%);
--color-700: hsl(var(--hue), 75%, 30%);
--color-800: hsl(var(--hue), 80%, 20%);
--color-900: hsl(var(--hue), 85%, 10%);
}
/* Change the entire palette by changing one number */
.theme-purple { --hue: 270; }
.theme-teal { --hue: 175; }
.theme-rose { --hue: 350; }
Naming Conventions
Use lowercase with hyphens (--color-primary), prefix by category (--color-*, --space-*, --font-*), prefer semantic names in the semantic layer (--color-text-muted over --gray-400), namespace component properties (--card-bg, --btn-radius), and include state in the name where needed (--btn-bg-hover, --input-border-focus).
Performance Considerations
CSS custom properties are well-optimized in all modern browsers, but understanding the performance characteristics helps you avoid edge cases.
What Is Fast
- Defining hundreds of custom properties on
:root— browsers handle this efficiently. A design token system with 200+ properties is not a performance concern. - Using
var()in normal property values — the resolution cost is negligible compared to overall style computation. - Changing a custom property via class toggle — this triggers normal style recalculation, same as changing any other property.
- Scoped custom properties on individual components — this is well optimized because changes only affect descendants of the element.
What to Watch Out For
- Deeply nested
var()fallback chains —var(--a, var(--b, var(--c, var(--d, red))))requires the browser to resolve each layer. Keep nesting to two or three levels maximum. - High-frequency JavaScript updates on
:root— updatingdocument.documentElement.styleon everymousemoveorscrollevent triggers style recalculation on the entire document. UserequestAnimationFrameand scope the property to the most specific element possible. - Custom properties in animation keyframes (without @property) — the browser recalculates styles on every animation frame. Registered
@propertyvalues are more efficient because the browser knows the type and can optimize interpolation.
Performance Best Practice
/* Scope dynamic properties to the element that needs them */
/* LESS OPTIMAL: setting on root, recalculates entire document */
document.documentElement.style.setProperty('--tooltip-x', x + 'px');
/* MORE OPTIMAL: setting on the specific element */
tooltip.style.setProperty('--tooltip-x', x + 'px');
/* Throttle high-frequency updates */
let ticking = false;
window.addEventListener('scroll', () => {
if (!ticking) {
requestAnimationFrame(() => {
document.documentElement.style.setProperty(
'--scroll-y',
window.scrollY + 'px'
);
ticking = false;
});
ticking = true;
}
});
Browser Support
CSS custom properties (var()) have been supported in all major browsers since 2017:
- Chrome: version 49+ (March 2016)
- Firefox: version 31+ (July 2014)
- Safari: version 9.1+ (March 2016)
- Edge: version 15+ (April 2017, all Chromium-based versions)
Global browser support in 2026 exceeds 97%. There is no practical reason to avoid custom properties in any modern web project.
The @property at-rule has slightly newer support:
- Chrome: version 85+ (August 2020)
- Firefox: version 128+ (July 2024)
- Safari: version 15.4+ (March 2022)
- Edge: version 85+ (August 2020)
As of 2026, @property is supported in all current browser versions. For projects that must support older Firefox versions (pre-128), use @property as progressive enhancement: the animation will snap instead of interpolate, but the layout and styling remain correct.
Comparison with Preprocessor Variables (Sass/Less)
If you are migrating from Sass or Less, understanding the differences helps you choose the right tool for each job.
/* Sass variable: compiled away, static output */
$primary: #3b82f6;
.btn { background: $primary; }
/* Compiled CSS: .btn { background: #3b82f6; } */
/* CSS custom property: exists at runtime, dynamic */
:root { --primary: #3b82f6; }
.btn { background: var(--primary); }
/* Output CSS is the same as the source */
| Feature | CSS Custom Properties | Sass/Less Variables |
|---|---|---|
| Runtime? | Yes — live in the browser | No — compiled to static CSS |
| Cascade and inherit? | Yes — follows DOM tree | No — lexical scope only |
| JavaScript access? | Yes — read/write via CSSOM | No — not accessible after compilation |
| Media query responsive? | Yes — redefine values per breakpoint | No — values are fixed at compile time |
| Loops and conditionals? | No | Yes — @each, @for, @if |
| Mixins and functions? | No | Yes — @mixin, @function |
| Build tools required? | No | Yes — Sass/Less compiler |
| Theming support? | Excellent — runtime theme switching | Limited — requires generating multiple stylesheets |
The modern approach is to use both: Sass for build-time code generation (loops, math, utility class generation) and CSS custom properties for anything dynamic at runtime (theming, user preferences, responsive values). Sass can generate your custom property declarations at build time, producing clean CSS that the browser uses dynamically.
Frequently Asked Questions
What is the difference between CSS custom properties and Sass variables?
CSS custom properties exist at runtime in the browser: they cascade, inherit through the DOM, and can be changed dynamically with JavaScript or media queries. Sass variables compile to static values at build time. Use custom properties for theming and runtime values; use preprocessor variables for build-time code generation.
Can CSS custom properties be animated?
Not by default — the browser treats them as opaque strings. Register a property with @property to declare its type (<color>, <length>, <number>), and the browser can then interpolate between values for smooth transitions and keyframe animations.
How do I implement dark mode with custom properties?
Define your color palette as custom properties on :root, then override them inside @media (prefers-color-scheme: dark) or a [data-theme="dark"] selector. For manual toggle support, use JavaScript to set a data-theme attribute on <html> and persist the choice in localStorage.
What are design tokens?
Design tokens are the atomic values of a design system. CSS custom properties implement them in layers: primitives (--blue-500), semantic tokens (--color-primary: var(--blue-500)), and component tokens (--btn-bg: var(--color-primary)). Changing one primitive cascades to every referencing component.
Do custom properties affect performance?
For typical usage, no. Browsers handle hundreds of custom properties with negligible overhead. Avoid deeply nested fallback chains (3+ levels), and scope high-frequency JavaScript setProperty() calls to the most specific element rather than :root.
What happens when a custom property is undefined?
The var() function uses its fallback if provided (var(--missing, blue) resolves to blue). Without a fallback, the property gets the CSS unset value. If the property exists but holds an incompatible value, the fallback is not used — the declaration becomes "invalid at computed value time" (IACVT) and resets to the inherited or initial value.
Summary
CSS custom properties are the foundation of modern CSS architecture. They provide a native variable system that works at runtime, respects the cascade, inherits through the DOM, and integrates seamlessly with JavaScript. Here is what to take away from this guide:
- Declare with
--name, use withvar(--name). Define on:rootfor global availability, or on specific elements for scoped overrides. - Custom properties cascade and inherit through the DOM tree, enabling contextual theming where components adapt to their location without explicit configuration.
- Fallback values (
var(--name, fallback)) provide defaults, but understand the IACVT behavior when a property exists with an incompatible value. - Design token systems with primitive, semantic, and component layers keep large codebases maintainable and themeable.
- Dark mode is best implemented by combining
prefers-color-schemefor automatic detection with adata-themeattribute for manual override. - Responsive design benefits from redefining custom property values in media query blocks, centralizing breakpoint logic away from component CSS.
@propertyunlocks animated custom properties, type safety, and controlled inheritance — register typed properties whenever you need transitions or animation.- JavaScript integration via
getComputedStyleandsetPropertybridges CSS and JS efficiently. Scope dynamic updates to the most specific element to minimize recalculation. - Combine with preprocessors: use Sass/Less for build-time generation and CSS custom properties for runtime dynamism.
Custom properties are supported in every current browser with over 97% global coverage. There is no reason to defer adoption. Whether you are building a component library, a design system, a theme engine, or just trying to keep your colors consistent, CSS custom properties should be foundational to your approach.
For more CSS techniques, explore our CSS Grid Complete Guide for layout, the CSS Nesting Guide for modern selector syntax, and the CSS Selectors Cheat Sheet for quick selector reference. Use our Color Picker to find the perfect values for your custom properties and the CSS Gradient Generator to create gradient values.