CSS Variables: The Secret to Dynamic, Modern Theming

By LearnWebCraft Team13 min readbeginner
CSSCSS VariablesThemingCustom PropertiesDark Mode

Okay, let's talk. I want you to think back to a time—maybe it was last week, maybe five years ago—when you had to change a primary color across an entire website. What did that look like?

If you were anything like me in the old days, it was probably a stressful Ctrl+F adventure. You'd search for #3B82F6, replace it with your new, hip shade of blue, and just pray you didn't accidentally change some other hex code that happened to be similar. It was messy. It was fragile. And honestly, it felt a little bit like we were using stone tools in the age of spaceships.

Then came the preprocessors like Sass and Less, which were a total godsend. They gave us variables! But... they had a secret: they weren't real browser variables. They were just a clever compile-time trick. They'd do the find-and-replace for you, but once the CSS was shipped to the browser, it was back to being static and, well, dumb.

Enter CSS Custom Properties, more affectionately known as CSS Variables. And let me tell you, they changed the game. Completely. They aren't just a developer convenience; they are a living, breathing part of the browser's engine. This is the story of how they work, why they’re so amazing, and how you can use them to build things that feel truly dynamic.

So, What Exactly Are CSS Variables?

Let's demystify this a bit. At their core, CSS variables are just values that you define once and can then reuse everywhere. It helps to think of them like nicknames for your design decisions.

Instead of telling every single button on your site to have a background color of #3b82f6, you can say, "Hey, let's create a nickname called --primary-brand-color and assign it the value #3b82f6."

From that point on, you just tell your buttons: "Your background is --primary-brand-color."

And here's the magic: if that brand color ever changes, you only have to update it in one single place. The browser sees the change and—poof—every single element using that nickname updates instantly. No find-and-replace, no recompiling. Just pure, real-time reactivity.

Here's what it looks like in the wild. Don't worry, we'll break it all down.

:root {
  --primary-color: #3b82f6;
  --base-spacing: 16px;
}

.button {
  background-color: var(--primary-color);
  padding: var(--base-spacing);
  border-radius: 8px;
}

See that? The var() function is our way of saying, "Hey browser, go look up the value for this nickname." It's that simple, but believe me, the implications are massive.

The Basic Syntax: Your First Variables

Alright, let’s get our hands dirty. There are really just two main parts to the syntax: defining the variable and then using it. It's a rhythm you'll get used to in no time.

Defining Variables: Global vs. Local Scope

Just like in JavaScript, variables in CSS have scope. This is an incredibly powerful feature that gives you a ton of control.

Global Scope: Most of the time, you'll probably define your variables on the :root pseudo-class. This is basically a fancy selector for the <html> element, and it makes your variables available everywhere in your document. It's the perfect spot for your design system's core tokens—colors, fonts, spacing units, you name it.

/* These are now available to every element on the page */
:root {
  --main-bg-color: #ffffff;
  --main-text-color: #1f2937;
  --standard-border-radius: 8px;
}

Local Scope: But sometimes, you want a variable that only exists inside a specific component. Maybe a .card component has its own special padding that nothing else uses. You can define a variable directly on that component's selector.

/* These variables only work inside elements with the class "card" */
.card {
  --card-padding: 20px;
  --card-shadow: 0 2px 4px rgba(0,0,0,0.1);
  
  padding: var(--card-padding);
  box-shadow: var(--card-shadow);
}

This is huge. It means a component can have its own internal "settings" that can even override the global ones.

Using Variables: The var() Function

To use a variable, you just call the var() function. Its first argument is simply the variable name you want to use.

But here’s a cool little trick: the var() function can also take a second argument—a fallback value. This is your safety net. If the browser can't find the variable you're asking for (maybe you fat-fingered the name or it's not in scope), it will use this fallback value instead.

.element {
  /* If --special-accent-color isn't defined, use tomato. Simple! */
  background-color: var(--special-accent-color, tomato);
  
  /* You can even chain them! How cool is that? */
  font-size: var(--component-font-size, var(--global-font-size, 16px));
}

This makes your code super resilient. It’s like telling the browser, "Try this first, but if that fails for any reason, here's Plan B."

The Killer Feature: Dynamic Theming and Dark Mode

Alright, this is the part that gets everyone really excited. This is where CSS variables go from a nice-to-have to an absolute game-changer for modern web development.

Because these variables are "live" in the browser, we can change them with CSS or even JavaScript, and the entire UI will react instantly. The most popular use case for this? You guessed it: a beautiful, seamless dark mode.

Let's build one right now. It's shockingly simple.

First, we define our theme tokens in :root. Think of these as our "light mode" defaults. Notice how we're using semantic names like --bg-primary instead of something like --white. This is key. We're describing the role of the color, not the color itself.

:root {
  --bg-primary: #ffffff;
  --bg-secondary: #f3f4f6;
  --text-primary: #1f2937;
  --text-secondary: #6b7280;
  --border-color: #e5e7eb;
}

Now, for the magic. We create a new rule that looks for an attribute on our <html> tag, like data-theme="dark". Inside this rule, we just... redefine the exact same variables with our new dark mode values.

[data-theme="dark"] {
  --bg-primary: #1f2937;
  --bg-secondary: #111827;
  --text-primary: #f9fafb;
  --text-secondary: #d1d5db;
  --border-color: #374151;
}

That's it. Seriously. That's the entire CSS for our dark mode logic. We haven't touched a single component's style. We just swapped out the foundational values.

Our components are already using these variables, so they don't need to change at all. They just adapt.

body {
  background-color: var(--bg-primary);
  color: var(--text-primary);
  transition: background-color 0.3s, color 0.3s; /* For a smooth fade */
}

.card {
  background-color: var(--bg-secondary);
  border: 1px solid var(--border-color);
}

Toggling the Theme with a Pinch of JavaScript

All we need now is a way to add or remove that data-theme="dark" attribute. A little sprinkle of JavaScript is perfect for the job.

<button id="theme-toggle">Toggle Theme</button>
const themeToggle = document.getElementById('theme-toggle');
const htmlElement = document.documentElement; // This is the <html> tag

themeToggle.addEventListener('click', () => {
  // Check the current theme
  const currentTheme = htmlElement.getAttribute('data-theme');
  
  if (currentTheme === 'dark') {
    // Switch to light
    htmlElement.setAttribute('data-theme', 'light');
  } else {
    // Switch to dark
    htmlElement.setAttribute('data-theme', 'dark');
  }
});

Boom. You have a working theme switcher. The CSS variables are doing all the heavy lifting. This pattern is so clean, so maintainable, and it honestly feels like you're unlocking a superpower.

Building a Mini Design System

Okay, let's take this a step further. Variables aren't just for colors. They are the absolute backbone of a robust design system. You can tokenize everything: spacing, typography, shadows, transitions—you name it.

This creates a single source of truth for your entire UI. When you establish this system, you stop guessing. No more margin-top: 15px here and margin-top: 1rem there. Everything becomes consistent and predictable.

A Cohesive Spacing System

Let's build a spacing scale. Using a consistent scale (like a 4px or 8px grid) is what makes layouts feel harmonious and intentional.

:root {
  --space-1: 4px;   /* 1 * 4px */
  --space-2: 8px;   /* 2 * 4px */
  --space-3: 12px;
  --space-4: 16px;  /* Base unit */
  --space-6: 24px;
  --space-8: 32px;
  --space-12: 48px;
  --space-16: 64px;
}

Now, when you're styling components, you just use the tokens.

.card {
  padding: var(--space-6); /* 24px */
  margin-bottom: var(--space-4); /* 16px */
}

.form-group {
  display: flex;
  flex-direction: column;
  gap: var(--space-2); /* 8px */
}

The beauty here is that you can adjust the entire feel of your site just by tweaking these base values. Need a little more breathing room everywhere? Maybe you increase all the values slightly. It's all centralized.

A Robust Typography System

The same logic applies beautifully to typography. You can define your font families, sizes, weights, and line heights all in one place.

:root {
  /* Font Families */
  --font-sans: 'Inter', system-ui, -apple-system, sans-serif;
  --font-serif: 'Georgia', serif;
  --font-mono: 'Fira Code', monospace;
  
  /* Font Sizes (using a modular scale) */
  --text-sm: 0.875rem; /* 14px */
  --text-base: 1rem;     /* 16px */
  --text-lg: 1.125rem; /* 18px */
  --text-xl: 1.25rem;  /* 20px */
  --text-2xl: 1.5rem;  /* 24px */
  
  /* Font Weights */
  --font-normal: 400;
  --font-semibold: 600;
  --font-bold: 700;
  
  /* Line Heights */
  --leading-normal: 1.5;
  --leading-relaxed: 1.75;
}

Applying this to your elements becomes an absolute dream. Your CSS starts reading like a design specification.

body {
  font-family: var(--font-sans);
  font-size: var(--text-base);
  line-height: var(--leading-normal);
}

h1 {
  font-size: var(--text-2xl);
  font-weight: var(--font-bold);
}

This kind of discipline pays off tenfold on larger projects. It’s the difference between a house built with carefully measured lumber and one built with random scraps of wood. Both might stand, but only one is engineered to last.

Even More Advanced Tricks

Just when you think you've seen it all, it turns out CSS variables have a few more tricks up their sleeve.

Responsive Variables

This one still blows my mind every time I use it. You can redefine CSS variables inside media queries.

Think about that for a second. Instead of writing complex overrides for dozens of properties at different breakpoints, you can just change a few core variables.

:root {
  --container-padding: var(--space-4); /* 16px default */
  --hero-font-size: var(--text-2xl); /* 24px default */
}

/* On larger screens... */
@media (min-width: 768px) {
  :root {
    --container-padding: var(--space-8); /* ...update the padding */
    --hero-font-size: var(--text-4xl); /* ...and the hero font size */
  }
}

.container {
  padding-left: var(--container-padding);
  padding-right: var(--container-padding);
}

.hero-title {
  font-size: var(--hero-font-size);
}

Look how clean that is! The component CSS (like .container and .hero-title) doesn't even know what a media query is. It just dutifully uses whatever the current value of the variable is. This is such a powerful way to decouple your components from your responsive logic.

Real-Time Updates with JavaScript

Remember how we said these variables are live? Well, you can manipulate them directly from JavaScript using style.setProperty(). This opens up a whole new world of interactive UIs driven by CSS.

Imagine, for example, some sliders that control the HSL values of a color.

<label>Hue: <input type="range" id="hue" min="0" max="360" value="220"></label>
<div class="box"></div>
:root {
  --hue: 220;
  --primary: hsl(var(--hue), 80%, 50%);
}
  
.box {
  width: 100px;
  height: 100px;
  background-color: var(--primary);
}
const root = document.documentElement;
const hueSlider = document.getElementById('hue');

hueSlider.addEventListener('input', (e) => {
  root.style.setProperty('--hue', e.target.value);
});

As you drag the slider, the JavaScript updates the --hue variable on the :root element. The browser instantly recalculates the --primary color and repaints the .box. It's incredibly performant and lets you create things like live theme customizers with just a few lines of code.

Some Friendly Advice: Best Practices

I've made my fair share of mistakes along the way, so here's some advice to help you avoid the same pitfalls.

  1. Name Things Semantically: Please, please, avoid naming variables after their values, like --red or --16px-padding. Instead, name them after their purpose. --color-error is so much better than --red. --spacing-md is better than --16px-padding. This lets you change the value later without the name becoming a lie.
  2. Organize Them: As your list of variables grows, it's a good idea to group them by category (Colors, Typography, Spacing, etc.) in your :root block. It just makes finding things so much easier down the road.
  3. Scope When It Makes Sense: Don't feel like you have to put everything in :root. If a variable truly only belongs to one component (like --card-header-height), define it right on that component. It helps keep your global namespace clean.
  4. Provide Fallbacks (for browsers and humans): While browser support is excellent these days, it's still good practice to provide a fallback for critical properties. This is especially true if you need to support something ancient like IE11, which doesn't support variables at all.
.button {
  background-color: #3b82f6; /* Fallback for very old browsers */
  background-color: var(--primary-color);
}

Wrapping It Up

If you take one thing away from all of this, let it be this: CSS variables are not just another CSS feature. They represent a fundamental shift in how we can think about and structure our stylesheets. They bridge that gap between static design tokens and a living, dynamic user interface.

They empower us to build systems that are more maintainable, more powerful, and frankly, a lot more fun to work on. From simple color management to complex, user-driven themes, they are the key.

So go ahead. Open up your next project, declare a :root, and give your design decisions some names. You're not just writing CSS anymore—you're designing a system. And trust me, your future self will thank you for it.

Frequently Asked Questions

Aren't CSS variables the same as variables in Sass or Less? Ah, a great question, and a common point of confusion. Not quite! Sass/Less variables are a preprocessor feature. They only exist in your source files. When you compile your code, they get replaced with their static values. CSS variables, on the other hand, exist directly in the browser. This is what allows them to be updated in real-time with CSS or JavaScript—something preprocessor variables could never do.

What's the browser support like? Can I really use them? Yes! The support is fantastic. All modern browsers (Chrome, Firefox, Safari, Edge) have had full support for years now. The only place you'll run into trouble is Internet Explorer 11. If you absolutely must support IE11, you'll need to provide static fallback values for every property where you use a variable. For most projects today, though, you can use them with total confidence.

Is there any performance impact from using a ton of CSS variables? For 99.9% of use cases, the performance impact is completely negligible. The browser's CSS engine (like the amazing Stylo engine in Firefox) is highly optimized to handle custom properties. While there's a tiny computational cost to resolving a variable's value, it's nothing you'd ever notice unless you're doing something extremely unusual, like updating thousands of variables hundreds of times per second. For theming and design systems, it's a non-issue.

What's the difference between var(--my-var) and var(--my-var, #fff)? The second version includes a fallback value. If the browser tries to find --my-var and can't (maybe it was never defined, or it's out of scope), it will use #fff instead. If you leave out the fallback and the variable can't be found, the property will be considered invalid and will revert to its default or inherited value. This can sometimes break your layout in unexpected ways, so it's a good habit to use fallbacks for critical properties.

Related Articles