CSS Custom Properties: A Complete Guide to CSS Variables
CSS custom properties, commonly called CSS variables, are a modern CSS feature that brings real programming-like variables to stylesheets. Unlike preprocessor variables (Sass, Less) that compile away at build time, CSS custom properties are live — they cascade, can be changed at runtime with JavaScript, and respond to media queries and user preferences. This makes them ideal for theming, responsive design, and maintaining consistent spacing and color systems across large projects.
CSS custom properties are supported in all modern browsers and have been since 2016. There is no longer any reason to use preprocessor variables for values that need to change at runtime.
Defining and Using Custom Properties
Custom properties are defined with a -- prefix and accessed with var():
:root {
--primary-color: #3498db;
--spacing-unit: 8px;
--border-radius: 4px;
---
.button {
background-color: var(--primary-color);
padding: var(--spacing-unit) calc(var(--spacing-unit) * 2);
border-radius: var(--border-radius);
---The :root selector matches the document root (<html>). Defining custom properties on :root makes them available globally. Properties defined on more specific elements override the global value through normal CSS cascading.
Fallback Values
The var() function accepts a fallback as a second argument:
.card {
background: var(--card-bg, #ffffff);
/* Uses --card-bg if defined, otherwise #ffffff */
---
/* Nested fallbacks */
.card {
padding: var(--padding, var(--spacing-default, 16px));
---Fallbacks are essential for progressive enhancement. When a custom property is not defined (or its value is invalid), the fallback is used. This lets you introduce custom properties gradually without breaking existing styles.
Scope and Inheritance
Custom properties follow the CSS cascade. A property defined on an element is available to that element and its descendants:
.card {
--card-padding: 20px;
background: var(--card-bg, #f0f0f0);
---
.card-header {
padding: var(--card-padding);
/* Inherits --card-padding: 20px from .card */
---
.card-footer {
--card-padding: 12px; /* Override for this element only */
padding: var(--card-padding);
---This scoping behavior is what makes custom properties fundamentally different from preprocessor variables. A Sass variable has one value for the entire compilation. A custom property can have different values on every DOM element.
Theming with Scoped Properties
Scoped custom properties enable component-level theming without class name conflicts:
.btn {
--btn-bg: #3498db;
--btn-color: white;
background: var(--btn-bg);
color: var(--btn-color);
---
.btn-danger {
--btn-bg: #e74c3c;
---
.btn-success {
--btn-bg: #27ae60;
---Each variant only overrides the properties that change. The base .btn class handles everything else. This pattern is much cleaner than defining separate classes with duplicated property declarations.
Dynamic Theming with JavaScript
Custom properties can be read and set with JavaScript, enabling runtime theme switching:
// Set a theme
document.documentElement.style.setProperty('--primary-color', '#9b59b6');
document.documentElement.style.setProperty('--background', '#2c3e50');
// Read a custom property
const primary = getComputedStyle(document.documentElement)
.getPropertyValue('--primary-color')
.trim();
// Toggle theme
function setTheme(theme) {
const themes = {
light: {
'--bg': '#ffffff',
'--text': '#333333',
'--primary': '#3498db'
},
dark: {
'--bg': '#1a1a2e',
'--text': '#e0e0e0',
'--primary': '#bb86fc'
}
};
const root = document.documentElement;
Object.entries(themes[theme]).forEach(([prop, val]) => {
root.style.setProperty(prop, val);
});
---This approach avoids the need for multiple stylesheet files, class toggling on the body, or CSS-in-JS solutions. The browser handles repainting affected elements automatically.
Persisting Themes
Combine custom properties with localStorage for persistent theme selection:
// On page load
const savedTheme = localStorage.getItem('theme') || 'light';
setTheme(savedTheme);
// On theme toggle
document.getElementById('theme-toggle').addEventListener('click', () => {
const newTheme = document.documentElement.style.getPropertyValue('--bg').trim() === '#ffffff'
? 'dark'
: 'light';
setTheme(newTheme);
localStorage.setItem('theme', newTheme);
---);Custom Properties and calc()
Custom properties shine when combined with calc() for dynamic sizing:
:root {
--grid-gap: 16px;
--column-count: 3;
---
.grid {
display: grid;
grid-template-columns: repeat(var(--column-count), 1fr);
gap: var(--grid-gap);
---
/* Responsive grid that changes column count */
@media (max-width: 768px) {
:root {
--column-count: 2;
}
---
@media (max-width: 480px) {
:root {
--column-count: 1;
}
---Spacing Systems
A spacing scale built with custom properties makes layout adjustments consistent:
:root {
--space-xs: 4px;
--space-sm: 8px;
--space-md: 16px;
--space-lg: 24px;
--space-xl: 32px;
--space-2xl: 48px;
---
.card {
padding: var(--space-md);
margin-bottom: var(--space-lg);
---
.card-header {
padding-bottom: var(--space-sm);
margin-bottom: var(--space-md);
border-bottom: 1px solid #ddd;
---Responsive Design with Custom Properties
Media queries can override custom properties at any level:
:root {
--font-size-base: 16px;
--container-padding: 40px;
--content-max-width: 1200px;
---
@media (max-width: 768px) {
:root {
--font-size-base: 14px;
--container-padding: 20px;
--content-max-width: 100%;
}
---
body {
font-size: var(--font-size-base);
---
.container {
padding: 0 var(--container-padding);
max-width: var(--content-max-width);
margin: 0 auto;
---The entire layout responds to the viewport by changing a few custom properties at the root level. Individual components do not need their own media queries — they just use var() references.
Responsive Typography
:root {
--text-sm: 0.875rem;
--text-base: 1rem;
--text-lg: 1.25rem;
--text-xl: 1.5rem;
--text-2xl: 2rem;
--heading-scale: 1.25;
---
h1 { font-size: calc(var(--text-base) * var(--heading-scale) * var(--heading-scale) * var(--heading-scale)); }
h2 { font-size: calc(var(--text-base) * var(--heading-scale) * var(--heading-scale)); }
h3 { font-size: calc(var(--text-base) * var(--heading-scale)); }
@media (max-width: 768px) {
:root {
--heading-scale: 1.15;
}
---Custom Properties and @property (CSS Houdini)
The @property rule (CSS Houdini) lets you register custom properties with type checking and default values:
@property --gradient-angle {
syntax: '<angle>';
inherits: false;
initial-value: 0deg;
---
.box {
--gradient-angle: 45deg;
background: linear-gradient(
var(--gradient-angle),
#ff6b6b,
#4ecdc4
);
transition: --gradient-angle 0.5s ease;
---
.box:hover {
--gradient-angle: 135deg;
---Registered properties can be animated and interpolated because the browser knows their type. Without @property, animating a custom property is not possible because the browser does not know whether the value is a number, a color, or a string.
Practical Examples
Consistent Color System
:root {
--hue-primary: 210;
--color-primary: hsl(var(--hue-primary), 70%, 50%);
--color-primary-light: hsl(var(--hue-primary), 70%, 80%);
--color-primary-dark: hsl(var(--hue-primary), 70%, 30%);
--color-text: hsl(0, 0%, 20%);
--color-text-muted: hsl(0, 0%, 50%);
--color-bg: hsl(0, 0%, 98%);
---
.button {
background: var(--color-primary);
color: white;
---
.button:hover {
background: var(--color-primary-dark);
---Fade-In Animation with Custom Timing
:root {
--fade-duration: 0.3s;
--fade-delay: 0s;
---
.fade-in {
opacity: 0;
animation: fadeIn var(--fade-duration) ease var(--fade-delay) forwards;
---
@keyframes fadeIn {
to { opacity: 1; }
---
.staggered-item:nth-child(1) { --fade-delay: 0s; }
.staggered-item:nth-child(2) { --fade-delay: 0.1s; }
.staggered-item:nth-child(3) { --fade-delay: 0.2s; }Container Queries with Custom Properties
.card {
--card-padding: 1rem;
--card-font-size: 1rem;
padding: var(--card-padding);
font-size: var(--card-font-size);
---
@container (min-width: 400px) {
.card {
--card-padding: 1.5rem;
--card-font-size: 1.125rem;
}
---
@container (min-width: 600px) {
.card {
--card-padding: 2rem;
--card-font-size: 1.25rem;
}
---Best Practices
Define design tokens as custom properties — Colors, spacing, typography, and breakpoints should be custom properties defined at :root. This creates a single source of truth for your design system.
Use descriptive names — --color-primary is better than --blue. --space-md is better than --spacing-16. Names should describe the purpose, not the value.
Scope properties to components — Define component-specific custom properties on the component’s root element. This prevents naming collisions and makes component styles portable.
Provide fallbacks — Always provide a fallback value for var() when the custom property might not be defined, especially in component libraries.
Avoid !important with custom properties — If you need !important, you are fighting the cascade. Restructure your selectors instead.
Related: Learn about CSS Flexbox, CSS Grid, and responsive design.
CSS Variables with calc()
CSS custom properties combine powerfully with calc() for theme customization. Define a spacing scale as custom properties: --space-xs: 0.25rem; --space-sm: 0.5rem; --space-md: 1rem; --space-lg: 2rem;. Use calc() to derive related values: --gap: var(--space-md); --gap-double: calc(var(--gap) * 2);. This creates a design system with mathematical relationships between values. JavaScript can read and modify custom properties through element.style.setProperty('--variable', value), enabling dynamic theming without additional CSS.
Custom Property Fallbacks
Provide fallback values for custom properties to ensure graceful degradation: color: var(--text-color, #333). The second argument is the fallback. Nested fallbacks (var(--primary, var(--blue, #0066cc))) chain through multiple potential sources. Use this pattern for progressive enhancement — newer browsers get the full custom property experience while older browsers use the fallback.
Web Performance Metrics
Understanding performance metrics helps prioritize optimization efforts. First Contentful Paint (FCP): when the first text or image appears — target under 1.8 seconds. Largest Contentful Paint (LCP): when the main content loads — target under 2.5 seconds. Interaction to Next Paint (INP): responsiveness to user interactions — target under 200 milliseconds. Cumulative Layout Shift (CLS): visual stability — target under 0.1. First Input Delay (FID): time to first interaction — target under 100 milliseconds. Time to First Byte (TTFB): server response time — target under 800 milliseconds. Use Google’s Lighthouse, WebPageTest, and Chrome DevTools to measure these metrics. Real User Monitoring (RUM) captures actual user experiences, which may differ from synthetic tests. Set up dashboards tracking Core Web Vitals and alert when thresholds are breached.
Accessibility Beyond Compliance
Web accessibility ensures your site works for everyone, including people using screen readers, keyboard navigation, or other assistive technologies. WCAG 2.2 guidelines organize requirements into four principles: Perceivable (content must be available to senses), Operable (interface must be usable), Understandable (content and interface must be comprehensible), and Robust (content must work with current and future technologies). Practical steps: use semantic HTML elements (nav, main, aside, article), provide alt text for images, ensure color contrast ratios meet WCAG AA standards (4.5:1 for normal text), support keyboard navigation with visible focus indicators, and test with screen readers (NVDA, VoiceOver, JAWS). Accessibility benefits everyone — captions help users in noisy environments, keyboard navigation helps users with repetitive strain injuries, and high contrast helps users in bright sunlight.
FAQ
What level of expertise is required? The content assumes basic programming knowledge but explains concepts in depth. Beginners may need to reference prerequisite topics.
How do I know if I am implementing this correctly? Write tests that verify behavior, use linters and static analysis, and get code reviews from experienced practitioners.
What are the most common pitfalls? Over-engineering, premature optimization, and neglecting testing are the most frequent mistakes. Start simple, measure, and iterate.