CSS Variables & Theming
Defining & Using Custom Properties
Custom properties start with -- (two dashes) and are used with the var() function. They're case-sensitive and can hold any valid CSS value — colors, sizes, durations, even partial values.
/* Declare on :root — globally available */ :root { --color-primary: #2de8c0; --color-bg: #05091a; --color-text: #eef3ff; --font-size-base: 1rem; --spacing-md: 1.5rem; --radius: 12px; --transition: 0.2s ease; } /* Use with var() */ .btn { background: var(--color-primary); border-radius: var(--radius); padding: var(--spacing-md); transition: var(--transition); } /* Works anywhere a value is valid */ .card { border: 1px solid var(--color-primary); box-shadow: 0 4px 20px rgba(45, 232, 192, 0.2); font-size: var(--font-size-base); } /* Variables can reference other variables */ :root { --color-primary-rgb: 45, 232, 192; --color-primary-alpha: rgba(var(--color-primary-rgb), 0.15); }
#2de8c0 and it appears in 50 places in your CSS, changing the brand requires 50 edits. With --color-primary, you change one line and every element updates. Variables also allow things regular values can't: runtime switching, component overrides, and JavaScript integration.:root { --space-1: 0.25rem; /* 4px */ --space-2: 0.5rem; /* 8px */ --space-4: 1rem; /* 16px */ --space-8: 2rem; /* 32px */ } .card { padding: var(--space-4); gap: var(--space-2); /* calc() works great with variables */ width: calc(100% - var(--space-8)); } /* Stagger animations with calc + --i */ .card { animation-delay: calc(var(--i) * 0.1s); } /* Set --i from HTML: style="--i: 1" */
Scope: :root vs Element Scope
CSS variables cascade and inherit just like regular CSS. When you declare a variable on an element, it's only available to that element and its descendants — this is the key to component-level theming.
/* Global — available everywhere */ :root { --accent: #2de8c0; --bg: #05091a; } /* Scoped — override just for this component */ .card--danger { --accent: #f87171; /* only inside .card--danger */ --bg: rgba(248,113,113,.08); } .card--success { --accent: #4ade80; --bg: rgba(74,222,128,.08); } /* The .card styles are the same — only the vars change */ .card { background: var(--bg); border: 1px solid var(--accent); color: var(--accent); }
--accent changes per card..card-a { --accent: #2de8c0; }
.card-b { --accent: #f87171; }
.card-c { --accent: #fbbf24; }
Fallback Values & JavaScript Integration
The var() function accepts an optional fallback value — used if the variable isn't defined. And since CSS variables live in the DOM's computed style, JavaScript can read and write them at runtime, enabling dynamic theming without reloading the page.
/* var(--property, fallback) */ .btn { /* If --btn-bg isn't defined, use #2de8c0 */ background: var(--btn-bg, #2de8c0); /* Fallback can be another variable */ color: var(--btn-text, var(--color-text, #fff)); } /* Nested fallbacks (chain of backups) */ .card { border-radius: var(--card-radius, var(--radius, 12px)); }
// READ a CSS variable const value = getComputedStyle(document.documentElement) .getPropertyValue('--color-primary').trim(); // → "#2de8c0" // WRITE a CSS variable (instantly updates all elements using it) document.documentElement.style.setProperty('--color-primary', '#f97316'); // REMOVE (resets to :root value) document.documentElement.style.removeProperty('--color-primary'); // Scoped to an element (not just :root) const card = document.querySelector('.card'); card.style.setProperty('--accent', '#a78bfa');
↑ Use the controls to update CSS variables — all cards change at once
Dark / Light Mode Toggle
CSS variables make theme switching trivial. Define your entire color palette as variables on :root, then override those same variables under a [data-theme="light"] attribute (or body.light class). One JavaScript toggle, zero page reloads.
/* ─── DARK THEME (default) ─── */ :root { --bg: #05091a; --bg-card: #0a1230; --text: #eef3ff; --text-muted: #8ca8d8; --border: rgba(255,255,255,.08); --accent: #2de8c0; } /* ─── LIGHT THEME — override the same vars ─── */ [data-theme="light"] { --bg: #f8fafc; --bg-card: #ffffff; --text: #0f172a; --text-muted: #64748b; --border: #e2e8f0; --accent: #0d9488; } /* ─── USE the variables everywhere ─── */ body { background: var(--bg); color: var(--text); } .card { background: var(--bg-card); border: 1px solid var(--border); } /* ─── RESPECT system preference automatically ─── */ @media (prefers-color-scheme: light) { :root { /* apply light theme vars here */ } } // JavaScript — one liner! const toggle = () => { const current = document.documentElement.dataset.theme; document.documentElement.dataset.theme = current === 'light' ? 'dark' : 'light'; };
data-theme attribute.@media (prefers-color-scheme: dark), then let users override with a toggle stored in localStorage. On page load, read localStorage and apply data-theme before rendering to prevent a flash of wrong theme.Dynamic Theming Patterns
CSS variables unlock design patterns that were previously impossible without JavaScript frameworks. Here are the most useful ones for real projects.
/* Store color as RGB channels — then use rgba() for any opacity */ :root { --accent-rgb: 45, 232, 192; /* just the numbers */ } /* Now generate any opacity without repeating the color */ .card-hover { background: rgba(var(--accent-rgb), 0.1); } .card-border { border-color: rgba(var(--accent-rgb), 0.3); } .card-glow { box-shadow: 0 0 30px rgba(var(--accent-rgb), 0.4); }
:root { --space-unit: 1rem; /* base unit */ } /* Tighten everything on mobile by changing ONE variable */ @media (max-width: 640px) { :root { --space-unit: 0.75rem; } } .section { padding: calc(var(--space-unit) * 4); } .card { padding: calc(var(--space-unit) * 1.5); } .btn { padding: calc(var(--space-unit) * 0.5) calc(var(--space-unit) * 1.5); }
// Let users pick their brand color — update the whole UI instantly function setAccentColor(hex) { document.documentElement.style.setProperty('--accent', hex); localStorage.setItem('accent-color', hex); } // On page load, restore saved preference const saved = localStorage.getItem('accent-color'); if (saved) setAccentColor(saved);
| Pattern | Use case | Key technique |
|---|---|---|
| Dark/light theme | Site-wide color switching | [data-theme] attribute + var() overrides |
| Component variants | danger/success/warning cards | Scoped var() + single shared CSS rule |
| Responsive scale | Spacing adapts to viewport | Redefine base var in @media |
| Alpha variants | Multiple opacities of one color | RGB store + rgba(var(), opacity) |
| User customization | User-chosen accent color | JS setProperty() + localStorage |
primary-color?--accent: teal on :root, but override --accent: red on a .card element. What color will a <p> inside .card see for var(--accent)?var(--gap, 1rem) mean?Create a single-page layout with: (1) a
:root block with at least 6 CSS variables (bg, card-bg, text, text-muted, border, accent), (2) a [data-theme="light"] override block that redefines all of them for light mode, (3) a nav with a toggle button that swaps the theme on click, (4) at least one card component, a heading, a paragraph, and a button — all using var() for all colors. Bonus: store the selected theme in localStorage and restore it on page load.
💡 Show hints
- Put
data-theme="dark"on<html>by default - Toggle:
document.documentElement.dataset.theme = isLight ? 'dark' : 'light' - Add
transition: background 0.3s ease, color 0.3s easetobodyfor smooth switching - Use
localStorage.setItem('theme', value)to persist, and read on load withlocalStorage.getItem('theme') - The toggle icon:
☀️when dark mode is active (click to go light),🌙when light