CSS var() -- CSS VARIABLES • CUSTOM PROPERTIES • THEMING
Frontend

CSS Variables: Dynamic Styling Made Easy

Mayur Dabhi
Mayur Dabhi
March 30, 2026
18 min read

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.

What You'll Learn
  • 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 Variable Syntax Breakdown Declaration (Defining) --primary-color: #264de4; ↑ name value ↑ Usage (Consuming) color: var(--primary-color); var() ↑

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.

styles.css
/* 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.

fallbacks.css
.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));
}
Gotcha: the fallback isn't a type check

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 Variable Inheritance & Scoping :root --primary: #264de4 .theme-light (inherits) --primary: #264de4 ✓ --bg: #ffffff (new) .theme-dark (overrides) --primary: #6c8aff ✗ --bg: #000000 (new) .card (child) uses --primary → #264de4 .card (child) uses --primary → #6c8aff Variables cascade down • Children inherit parent values

CSS Variables follow the cascade and can be overridden at any level

scoping.css
/* 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';
    }
  });
Avoid Flash of Wrong Theme

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.

animations.css
/* 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.

dynamic-styling.js
// 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).

responsive-variables.css
/* 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
Best Practice

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.

Browser Support for CSS Variables Chrome 49+ ✓ Since 2016 Firefox 31+ ✓ Since 2014 Safari 9.1+ ✓ Since 2016 Edge 15+ ✓ Since 2017 IE 11 Not Supported ✗ Use fallbacks Supported in all modern browsers

Fallback Strategies

fallback-strategies.css
/* 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.

1

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.

2

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.

3

Define at :root for Globals

Use :root for theme-wide variables. Use component selectors for scoped variables that shouldn't leak globally.

4

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.

5

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.

CSS Variables Theming Custom Properties Frontend
Mayur Dabhi

Mayur Dabhi

Full Stack Developer with 5+ years of experience specializing in Laravel, React, and modern web technologies. Passionate about clean code and sharing knowledge.