CSS Variables: Dynamic Styling Made Easy
The brand color changes from #264de4 to something slightly different, and suddenly you're running find-and-replace across fifteen stylesheets, hoping you didn't miss a hover state or a hardcoded shade in some forgotten component. Then someone asks for dark mode, and you realize you'd need a second copy of half your CSS. Sass variables don't help here either, because they're gone once the CSS is compiled.
CSS Variables (officially CSS Custom Properties) fix this by living in the browser at runtime. This post covers the syntax, how scoping and inheritance work, a dark/light theme setup, animations, reading and writing variables from JavaScript, responsive values, and a few patterns I use in real projects, plus the gotchas that tend to bite.
- CSS Variable syntax and declaration
- Scoping, inheritance, and the cascade
- Building theme systems (dark/light mode)
- Dynamic animations with CSS Variables
- JavaScript integration and live updates
- Real-world patterns and best practices
- Browser support and fallback strategies
Understanding CSS Variables Syntax
There are only two pieces to remember. You define a variable with a double hyphen prefix (--) and read it with the var() function. Names are case-sensitive, so --Primary and --primary are two different variables.
CSS Variables are declared with -- prefix and consumed using var() function
Basic Declaration and Usage
Most of the time you'll declare them on :root so they're available everywhere. You can also declare them on any selector if you only want them to apply inside that part of the page.
/* Global variables - available everywhere */
:root {
/* Colors */
--primary-color: #264de4;
--secondary-color: #9b59b6;
--background: #ffffff;
--text-color: #1a1a1a;
/* Spacing */
--spacing-xs: 4px;
--spacing-sm: 8px;
--spacing-md: 16px;
--spacing-lg: 24px;
--spacing-xl: 32px;
/* Typography */
--font-family: 'Inter', sans-serif;
--font-size-base: 16px;
--line-height: 1.6;
/* Borders & Shadows */
--border-radius: 8px;
--shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
/* Using the variables */
.button {
background: var(--primary-color);
color: white;
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--border-radius);
font-family: var(--font-family);
box-shadow: var(--shadow);
}
.card {
background: var(--background);
color: var(--text-color);
padding: var(--spacing-lg);
border-radius: var(--border-radius);
}
Fallback Values
var() takes an optional second argument that's used when the variable isn't defined. It's handy for components that might be dropped into a page where nobody set the variable.
.element {
/* Simple fallback */
color: var(--undefined-var, #333);
/* Fallback to another variable */
background: var(--accent-color, var(--primary-color, blue));
/* Fallback with complex value */
box-shadow: var(--custom-shadow, 0 4px 12px rgba(0,0,0,0.15));
}
The fallback only kicks in when the variable is undefined. If --gap is set to red and you write margin: var(--gap, 16px), you don't get 16px. The declaration becomes "invalid at computed-value time" and the property falls back to its inherited or initial value, so the margin quietly becomes 0. DevTools won't flag it as an error either. When a style mysteriously disappears, check the computed value of the variable first. And note that the second argument does nothing for browsers that don't understand var() at all; see the fallback strategies further down for that.
Scoping and Inheritance
This is where custom properties behave very differently from Sass variables. They're regular CSS properties, so they take part in the cascade and inherit down the DOM tree. Override a variable on a parent and every descendant that reads it picks up the new value.
CSS Variables follow the cascade and can be overridden at any level
/* Global scope */
:root {
--primary: #264de4;
--text: #1a1a1a;
}
/* Component-scoped override */
.danger-zone {
--primary: #e74c3c; /* Red for this section */
}
/* All buttons use --primary */
.button {
background: var(--primary);
}
/* Button in .danger-zone will be red! */
Building a Theme System
Theming is the use case that sold most people on custom properties. You define the same set of variable names per theme, point every component at those names, and switching themes becomes a single attribute change on <html>.
/* Define theme variables */
[data-theme="light"] {
--bg-primary: #ffffff;
--bg-secondary: #f8f9fa;
--text-primary: #1a1a1a;
--text-muted: #666666;
--border-color: #e0e0e0;
--shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
[data-theme="dark"] {
--bg-primary: #000000;
--bg-secondary: #111111;
--text-primary: #ffffff;
--text-muted: #888888;
--border-color: #2a2a2a;
--shadow: 0 2px 8px rgba(255, 255, 255, 0.05);
}
/* Use variables throughout */
body {
background: var(--bg-primary);
color: var(--text-primary);
transition: background 0.3s, color 0.3s;
}
.card {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
box-shadow: var(--shadow);
}
<!-- Set default theme on html element -->
<html lang="en" data-theme="dark">
<head>
<!-- Prevent flash by setting theme early -->
<script>
const theme = localStorage.getItem('theme') ||
(window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark' : 'light');
document.documentElement.dataset.theme = theme;
</script>
</head>
<body>
<button id="themeToggle">
Toggle Theme
</button>
</body>
</html>
const themeToggle = document.getElementById('themeToggle');
// Toggle theme function
function toggleTheme() {
const html = document.documentElement;
const currentTheme = html.dataset.theme;
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
html.dataset.theme = newTheme;
localStorage.setItem('theme', newTheme);
}
themeToggle.addEventListener('click', toggleTheme);
// Listen for system preference changes
window.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', (e) => {
if (!localStorage.getItem('theme')) {
document.documentElement.dataset.theme =
e.matches ? 'dark' : 'light';
}
});
Always set the theme in a blocking <script> in the <head> before your CSS loads. This prevents users from seeing a flash of the wrong theme on page load.
Dynamic Animations
By default the browser doesn't know what type a custom property holds, so if you transition or animate one it just jumps from the start value to the end value. Registering it with @property gives it a type (an angle, a length, a color), and then it interpolates smoothly. Another option is to animate a real property like transform that happens to read the variable, as in the card hover example below.
/* Animatable custom properties (with @property) */
@property --gradient-angle {
syntax: '<angle>';
initial-value: 0deg;
inherits: false;
}
.gradient-border {
--gradient-angle: 0deg;
background: linear-gradient(
var(--gradient-angle),
#264de4,
#9b59b6,
#2ecc71
);
animation: rotate-gradient 3s linear infinite;
}
@keyframes rotate-gradient {
to {
--gradient-angle: 360deg;
}
}
/* Progress bar with variable */
.progress-bar {
--progress: 0%;
width: var(--progress);
transition: width 0.5s ease-out;
}
/* Hover effect with variable */
.card {
--lift: 0;
transform: translateY(calc(var(--lift) * -8px));
box-shadow: 0 calc(var(--lift) * 10px) calc(var(--lift) * 30px) rgba(0,0,0,0.1);
transition: transform 0.3s, box-shadow 0.3s;
}
.card:hover {
--lift: 1;
}
JavaScript Integration
You can read and write custom properties from JavaScript. That means JS only has to push a number or a color into one variable, and the CSS decides what to do with it, instead of you toggling classes or writing a pile of inline styles.
Reading Variables
Use getComputedStyle() to read the current value of any CSS variable from any element. The result comes back as a string, often with a leading space, so trim it.
Writing Variables
Use element.style.setProperty() to dynamically set CSS variable values from JavaScript.
// Reading CSS Variables
const root = document.documentElement;
const styles = getComputedStyle(root);
const primaryColor = styles.getPropertyValue('--primary-color').trim();
console.log(primaryColor); // "#264de4"
// Writing CSS Variables
root.style.setProperty('--primary-color', '#e74c3c');
// Dynamic mouse-following gradient
document.addEventListener('mousemove', (e) => {
const x = (e.clientX / window.innerWidth) * 100;
const y = (e.clientY / window.innerHeight) * 100;
root.style.setProperty('--mouse-x', `${x}%`);
root.style.setProperty('--mouse-y', `${y}%`);
});
// Use in CSS:
// background: radial-gradient(
// circle at var(--mouse-x) var(--mouse-y),
// var(--accent), transparent
// );
// Progress bar update
function updateProgress(percent) {
const progressBar = document.querySelector('.progress-bar');
progressBar.style.setProperty('--progress', `${percent}%`);
}
// Color picker integration
const colorPicker = document.querySelector('input[type="color"]');
colorPicker.addEventListener('input', (e) => {
root.style.setProperty('--user-accent', e.target.value);
});
Responsive Design with Variables
Instead of sprinkling media queries through every component, you can change the variable values at each breakpoint and let the components just read them.
One thing that trips people up: you can't use a variable inside the media query condition. @media (min-width: var(--bp-tablet)) is simply ignored. Breakpoints have to be literal values (or Sass variables, which get compiled away).
/* Base (mobile-first) values */
:root {
--container-width: 100%;
--container-padding: 16px;
--font-size-h1: 2rem;
--font-size-body: 1rem;
--grid-columns: 1;
--spacing-section: 40px;
}
/* Tablet */
@media (min-width: 768px) {
:root {
--container-padding: 24px;
--font-size-h1: 2.5rem;
--grid-columns: 2;
--spacing-section: 60px;
}
}
/* Desktop */
@media (min-width: 1200px) {
:root {
--container-width: 1200px;
--container-padding: 0;
--font-size-h1: 3.5rem;
--font-size-body: 1.1rem;
--grid-columns: 3;
--spacing-section: 100px;
}
}
/* Usage - no media queries needed in components! */
.container {
max-width: var(--container-width);
padding: 0 var(--container-padding);
margin: 0 auto;
}
h1 {
font-size: var(--font-size-h1);
}
.grid {
display: grid;
grid-template-columns: repeat(var(--grid-columns), 1fr);
}
section {
padding: var(--spacing-section) 0;
}
CSS Variables vs Preprocessor Variables
If you already use Sass or LESS, you might wonder whether you need both. They solve different problems, which the table makes fairly obvious:
| Feature | CSS Variables | Sass/LESS Variables |
|---|---|---|
| Runtime Changes | ✅ Yes - can change via JS | ❌ No - compiled at build time |
| Cascade & Inheritance | ✅ Full CSS cascade support | ❌ No cascade awareness |
| Scoping | ✅ DOM-aware scoping | ✅ File/block scoping |
| Media Query Changes | ✅ Supported natively | ❌ Requires duplication |
| Calculations | ✅ With calc() | ✅ Native math operators |
| Browser Support | ⚠️ IE11 not supported | ✅ Compiles to standard CSS |
| DevTools Inspection | ✅ Visible in browser | ❌ Variables not visible |
I use both. Sass variables for build-time constants like breakpoints in mixins, and CSS Variables for anything that has to change at runtime, like themes or user settings.
Real-World Patterns
These three come up in almost every project I build with custom properties.
Component Design Tokens
/* Global tokens */
:root {
/* Primitives */
--color-blue-500: #264de4;
--color-gray-100: #f8f9fa;
--radius-sm: 4px;
--radius-md: 8px;
/* Semantic tokens */
--color-primary: var(--color-blue-500);
--color-surface: var(--color-gray-100);
}
/* Component tokens */
.button {
--button-bg: var(--color-primary);
--button-radius: var(--radius-md);
--button-padding: 12px 24px;
background: var(--button-bg);
border-radius: var(--button-radius);
padding: var(--button-padding);
}
/* Variant override */
.button--small {
--button-padding: 8px 16px;
--button-radius: var(--radius-sm);
}
Fluid Typography
:root {
/* Fluid scale using clamp() */
--font-size-sm: clamp(0.875rem, 0.8rem + 0.25vw, 1rem);
--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.5rem);
--font-size-2xl: clamp(2rem, 1rem + 4vw, 4rem);
}
h1 { font-size: var(--font-size-2xl); }
h2 { font-size: var(--font-size-xl); }
p { font-size: var(--font-size-base); }
Color Manipulation with HSL
:root {
/* Store HSL components separately */
--primary-h: 227;
--primary-s: 79%;
--primary-l: 52%;
/* Compose the color */
--primary: hsl(
var(--primary-h),
var(--primary-s),
var(--primary-l)
);
/* Create variants easily! */
--primary-light: hsl(
var(--primary-h),
var(--primary-s),
calc(var(--primary-l) + 15%)
);
--primary-dark: hsl(
var(--primary-h),
var(--primary-s),
calc(var(--primary-l) - 15%)
);
/* Semi-transparent version */
--primary-alpha: hsla(
var(--primary-h),
var(--primary-s),
var(--primary-l),
0.2
);
}
Browser Support
Every current browser supports custom properties and has for years. The one holdout is IE11, which only matters if you're still stuck supporting it.
Fallback Strategies
/* Strategy 1: Duplicate declarations */
.button {
background: #264de4; /* Fallback */
background: var(--primary);
}
/* Strategy 2: @supports query */
.card {
background: #ffffff;
}
@supports (--css: variables) {
.card {
background: var(--surface);
}
}
/* Strategy 3: CSS Variables fallback chain */
.element {
color: var(--custom, var(--default, #333));
}
Naming and Organizing Variables
Most of the pain I see with custom properties isn't technical. It's a :root block with 200 loosely named variables that nobody dares to touch. A few habits keep that from happening.
Use Descriptive Names
Name variables by their purpose (--color-primary), not their value (--blue). Otherwise the day the brand color turns green, you end up with --blue: green.
Organize with Prefixes
Group related variables: --color-*, --spacing-*, --font-*. Autocomplete in DevTools and your editor gets a lot more useful, and you're less likely to clash with a third-party library's variables.
Define at :root for Globals
Use :root for theme-wide variables. Use component selectors for scoped variables that shouldn't leak globally.
Add Fallbacks Where a Variable Might Be Missing
Use the second parameter in var() in shared components that could end up on a page where the variable was never defined. Inside your own app, where :root always sets it, it's mostly noise.
Combine with Preprocessors
Use Sass for build-time logic and mixins, CSS Variables for runtime theming. They don't compete with each other.
When I'd use CSS Variables (and when I wouldn't)
I reach for them whenever a value needs to change after the CSS ships: themes, user preferences, per-component overrides, or values pushed in from JavaScript. They're also nice for keeping spacing and color scales in one place.
I don't turn every single value into a variable, though. A one-off margin: 3px in one component doesn't need a token, and wrapping it in var() just adds a lookup when you read the code. Breakpoints can't be variables in media queries, so they stay in Sass or as literals. Be careful with high-frequency updates too: setting a variable on :root on every mousemove forces style recalculation for everything that inherits it, so on a large page set it on the smallest element that actually needs it. And if you still have to support IE11, you're back to duplicate declarations everywhere, which removes most of the benefit.