Difficulty: Advanced
CSS Custom Properties (also called CSS Variables) are entities defined by authors that contain specific values to be reused throughout a document. Defined using the -- prefix (e.g., --primary-color: #3b82f6) and accessed using the var() function, they bring the power of variables to CSS without needing a preprocessor like Sass or Less. Unlike preprocessor variables that are compiled away at build time, custom properties are live in the browser - they cascade, inherit, can be scoped, and can be changed dynamically with JavaScript.
Custom properties are typically defined on the :root pseudo-class (which represents the <html> element) to make them globally available. However, they follow the same cascade and inheritance rules as any CSS property. A custom property defined on a specific element or class overrides the global value within that scope. This scoping behavior is what makes custom properties incredibly powerful for component-based architectures - a card component can define its own --card-padding that only affects cards, without polluting the global namespace.
The var() function retrieves the value of a custom property. It accepts an optional second argument as a fallback value: var(--color, #333) uses #333 if --color is not defined. Fallback values can themselves contain var() references, enabling chains like var(--theme-color, var(--default-color, blue)). If a custom property is set to an invalid value for the property it is used in, the property receives its inherited value (not the fallback) - this is called the "invalid at computed-value time" behavior.
Dynamic theming is the most powerful application of custom properties. By defining a theme as a set of custom properties on :root, you can switch themes by changing just the variable values - either through a class toggle on the <html> element or via JavaScript's element.style.setProperty(). Dark mode becomes trivial: define light theme variables on :root and dark theme variables on :root.dark or [data-theme="dark"], then toggle the class or attribute. Every element referencing those variables updates automatically.
Custom properties can store any valid CSS value: colors, lengths, numbers, strings, and even entire property values like a complete box-shadow or gradient. They can be used in calc() expressions (calc(var(--spacing) * 2)), combined with other values (border: var(--border-width) solid var(--border-color)), and even animated in modern browsers that support @property for registered custom properties. The @property at-rule lets you declare the type, inheritance, and initial value of a custom property, enabling smooth transitions between values.
/* Define global custom properties on :root */
:root {
--color-primary: #3b82f6;
--color-secondary: #8b5cf6;
--color-text: #1f2937;
--color-bg: #ffffff;
--spacing-sm: 8px;
--spacing-md: 16px;
--spacing-lg: 32px;
--radius: 8px;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
--font-body: 'Inter', system-ui, sans-serif;
}
/* Using custom properties */
.card {
background: var(--color-bg);
color: var(--color-text);
padding: var(--spacing-lg);
border-radius: var(--radius);
box-shadow: var(--shadow);
font-family: var(--font-body);
}
.button {
background: var(--color-primary);
color: white;
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius);
border: none;
}
/* Fallback values */
.element {
color: var(--accent-color, #e11d48);
font-size: var(--custom-size, 16px);
margin: var(--custom-margin, var(--spacing-md));
}
Custom properties defined on :root are globally available. The var() function retrieves values with optional fallbacks. Fallbacks can be nested var() calls. Once defined, changing a single variable value updates every element that references it.
/* Global defaults */
:root {
--button-bg: #3b82f6;
--button-text: #ffffff;
--button-padding: 10px 20px;
--button-radius: 6px;
}
/* Base button uses global variables */
.button {
background: var(--button-bg);
color: var(--button-text);
padding: var(--button-padding);
border-radius: var(--button-radius);
border: none;
cursor: pointer;
}
/* Variant overrides via scoped custom properties */
.button-danger {
--button-bg: #ef4444;
--button-text: #ffffff;
}
.button-outline {
--button-bg: transparent;
--button-text: #3b82f6;
border: 2px solid var(--button-text);
}
.button-large {
--button-padding: 14px 32px;
--button-radius: 10px;
font-size: 18px;
}
/* Component-scoped variables */
.card {
--card-padding: 24px;
--card-bg: white;
padding: var(--card-padding);
background: var(--card-bg);
}
.card.compact {
--card-padding: 12px;
}
Custom properties cascade and inherit like regular CSS. Defining a variable on a specific class overrides it within that element's scope. This enables component variants without duplicating property declarations - just change the variable values.
/* Light theme (default) */
:root {
--bg-primary: #ffffff;
--bg-secondary: #f3f4f6;
--text-primary: #111827;
--text-secondary: #6b7280;
--border-color: #e5e7eb;
--shadow-color: rgba(0, 0, 0, 0.1);
--accent: #3b82f6;
}
/* Dark theme */
:root[data-theme="dark"] {
--bg-primary: #111827;
--bg-secondary: #1f2937;
--text-primary: #f9fafb;
--text-secondary: #9ca3af;
--border-color: #374151;
--shadow-color: rgba(0, 0, 0, 0.4);
--accent: #60a5fa;
}
/* Auto dark mode based on system preference */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg-primary: #111827;
--bg-secondary: #1f2937;
--text-primary: #f9fafb;
--text-secondary: #9ca3af;
--border-color: #374151;
--shadow-color: rgba(0, 0, 0, 0.4);
--accent: #60a5fa;
}
}
/* Components reference variables - no duplication needed */
body {
background: var(--bg-primary);
color: var(--text-primary);
}
.card {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
box-shadow: 0 2px 8px var(--shadow-color);
}
/* Toggle theme with JavaScript */
<script>
function toggleTheme() {
const current = document.documentElement.getAttribute('data-theme');
const next = current === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', next);
localStorage.setItem('theme', next);
}
</script>
Define your entire color palette as custom properties. Dark mode just redefines those variables - all components automatically adapt without any additional CSS. The data-theme attribute approach allows manual toggle that overrides system preference.
/* Change custom properties with JavaScript */
<script>
const root = document.documentElement;
// Set a custom property
root.style.setProperty('--accent', '#e11d48');
// Read a custom property
const accent = getComputedStyle(root).getPropertyValue('--accent');
console.log(accent); // #e11d48
// Remove a custom property (revert to inherited/fallback)
root.style.removeProperty('--accent');
// Dynamic spacing based on input
function updateSpacing(value) {
root.style.setProperty('--spacing-unit', value + 'px');
}
</script>
/* @property: Register typed custom properties (enables animation) */
@property --gradient-angle {
syntax: '<angle>';
initial-value: 0deg;
inherits: false;
}
.gradient-box {
--gradient-angle: 0deg;
background: conic-gradient(
from var(--gradient-angle),
#3b82f6,
#8b5cf6,
#ec4899,
#3b82f6
);
transition: --gradient-angle 0.5s ease;
}
.gradient-box:hover {
--gradient-angle: 180deg;
}
/* Animating custom properties with @keyframes */
@keyframes rotateGradient {
to { --gradient-angle: 360deg; }
}
.animated-gradient {
animation: rotateGradient 3s linear infinite;
}
JavaScript can read, set, and remove custom properties in real time. The @property at-rule registers a custom property with a specific type (angle, color, length, etc.), which enables the browser to interpolate and animate between values - something not possible with unregistered custom properties.
var(), :root, Fallback Values, Scope, Dynamic Theming