Mastering Dynamic Theming with CSS Custom Properties

By LearnWebCraft Team16 min readIntermediate
css custom propertiesdynamic themingdark modecss variablesweb design

Have you ever landed on a website, clicked that little sun or moon icon, and just watched the entire interface magically transform? It's such a delightful bit of user experience, isn't it? That seamless switch from a bright, crisp light mode to a cool, focused dark mode really does feel like modern magic. Today, we're going to pull back the curtain and show you exactly how that magic works. And spoiler alert: it’s not magic at all, it’s dynamic theming with CSS custom properties.

If you've ever tried to build a theme switcher before the era of CSS variables, you might have a few scars. I know I do. Maybe you juggled multiple stylesheets, wrote mountains of overriding CSS selectors, or leaned heavily on a preprocessor to spit out different builds. It was... well, let's just say it was clunky.

But we're living in a much better time now. We're going to dive deep into the modern, elegant, and honestly, surprisingly simple way to build powerful, flexible themes for any website or application.

Introduction to Dynamic Theming

So, what are we actually talking about when we say "theming"?

What is Dynamic Theming?

Dynamic theming is simply the ability for a user to change the visual appearance of a website or app in real-time, all without needing a page reload. This usually involves swapping out colors, fonts, spacing, or other visual styles. The most common example, of course, is that beloved Light/Dark mode toggle. But it can be so much more—think high contrast themes for accessibility, different brand themes for whitelabeled products, or even just fun seasonal color palettes.

The key word here is dynamic. The change happens instantly, right there in the browser, all triggered by the user's interaction.

Why Choose CSS Custom Properties for Theming?

Enter our hero: CSS Custom Properties, which you'll also hear called CSS Variables. These are properties you can define yourself and then reuse all over your stylesheet.

It helps to think of them like variables in JavaScript, but living directly inside your CSS. You can store a value (like a color or a font size) in one central place and then reference it wherever you need it. The best part? They're "live" and can be updated with JavaScript, which is the secret sauce that makes dynamic theming possible.

Benefits Over Traditional Methods (e.g., Preprocessor Variables)

I definitely remember the "good old days" of using Sass variables for theming. You'd have something like $primary-color: #3498db; and use that variable everywhere. It was a massive step up from hardcoding hex codes, for sure.

But Sass variables have one major limitation: they are compile-time.

What that means is the Sass compiler processes your .scss files, finds every instance of $primary-color, and replaces it with the hardcoded value #3498db. Once the final .css file is generated and sent to the browser, the variable is gone. The browser has no idea $primary-color ever existed.

CSS Custom Properties, on the other hand, are runtime. They exist right there in the browser, which is a total game-changer. This means:

  1. They can be changed on the fly with a little bit of JavaScript.
  2. They are aware of the DOM cascade, meaning you can scope them to specific elements.
  3. They simplify your code, often eliminating the need for complex preprocessor logic or juggling multiple theme files.

This fundamental shift is what makes them so absolutely perfect for theming.

CSS Custom Properties: The Foundation

Before we build our theme switcher, let's get the fundamentals down. I promise, they're surprisingly simple.

Declaring Custom Properties (--variable-name: value;)

You declare a custom property using a double-hyphen prefix (--), followed by whatever name you choose. It’s a strong convention to declare your global variables within the :root pseudo-class, which represents the <html> element. Doing this makes them available everywhere in your document.

:root {
  --color-primary: #007bff;
  --color-text: #333;
  --font-size-base: 16px;
  --border-radius: 4px;
}

See? It looks just like any other CSS property, but with that funky -- prefix.

Using Custom Properties (var(--variable-name);)

To use a variable, you just call the var() function and pass in the variable name as the argument.

body {
  font-size: var(--font-size-base);
  color: var(--color-text);
}

.button {
  background-color: var(--color-primary);
  border-radius: var(--border-radius);
}

This is where you can start to see the power. If you ever need to change your primary color, you just update the --color-primary value in :root one time, and every single element using it will instantly update. No find-and-replace needed.

Understanding Scope (Global vs. Local)

Variables declared in :root are global. But what's really cool is that you can also declare them on any other selector, making them local to that element and its children. This is a concept that preprocessors can't easily replicate.

Imagine you have a special callout box that needs a different, punchier theme.

.callout-box {
  --color-primary: #ffc107; /* A nice yellow */
  --color-text: #212529;

  background-color: #f8f9fa;
  border: 1px solid var(--color-primary);
  padding: 1rem;
}

.callout-box .button {
  /* This button will use the local --color-primary (yellow) */
  background-color: var(--color-primary);
  color: var(--color-text);
}

Any .button that happens to be inside a .callout-box will now be yellow, while buttons everywhere else on the page remain blue. This cascading nature is incredibly powerful for component-based design.

Fallback Values and Browser Support

What happens if a variable isn't defined for some reason? Does your style just break? Not necessarily. The var() function accepts an optional second parameter: a fallback value.

.alert {
  /* If --color-alert isn't defined, it will default to orange. */
  background-color: var(--color-alert, orange);
}

This is great for building resilient styles and for working with older browsers. Speaking of which, browser support for custom properties is excellent—it's supported in all modern browsers. Unless you're supporting Internet Explorer 11, you're good to go. And if you are... well, you have my deepest sympathies.

Building Your First Dynamic Theme

Alright, enough theory. Let's get our hands dirty and build something! We'll start with a simple light theme and then add a dark variant.

Defining a Base Theme (e.g., Light Mode)

First things first, let's set up our base theme variables in the :root. This will be our default "light mode." We'll define colors for the background, text, primary actions, and a secondary surface color for things like cards or modals.

/* themes.css */
:root {
  --color-background: #f8f9fa;
  --color-surface: #ffffff;
  --color-text-primary: #212529;
  --color-text-secondary: #6c757d;
  --color-primary: #007bff;
  --border-color: #dee2e6;
}

Applying Theme Properties to Elements

Now, let's apply these variables to some basic HTML elements to see them in action.

<!-- index.html -->
<body>
  <main class="container">
    <h1>Dynamic Theming is Fun!</h1>
    <p>Click the button below to see the magic.</p>
    <div class="card">
      <h2>This is a Card</h2>
      <p>It uses the surface color and has a subtle border.</p>
    </div>
    <button class="btn">Click Me</button>
  </main>
</body>

And here's the corresponding CSS that uses our new variables:

/* styles.css */
body {
  background-color: var(--color-background);
  color: var(--color-text-primary);
  font-family: sans-serif;
  transition: background-color 0.3s, color 0.3s;
}

.card {
  background-color: var(--color-surface);
  border: 1px solid var(--border-color);
  padding: 1rem;
  border-radius: 8px;
  margin-block: 1rem;
}

.btn {
  background-color: var(--color-primary);
  color: white;
  border: none;
  padding: 0.75rem 1.5rem;
  border-radius: 4px;
  cursor: pointer;
}

Did you notice the transition on the body? That's a little pro-tip that will give us a nice, smooth fade between themes instead of an abrupt flash.

Creating a Dark Mode Variant

Okay, this is the really fun part. To create our dark theme, we don't need to rewrite all our styles. We just need to redefine our variables within a specific class, something like .dark-theme.

/* themes.css - add this to your file */
.dark-theme {
  --color-background: #121212;
  --color-surface: #1e1e1e;
  --color-text-primary: #e0e0e0;
  --color-text-secondary: #a0a0a0;
  --color-primary: #1e90ff; /* A slightly brighter blue for dark mode */
  --border-color: #333333;
}

And that's it. Seriously. That's our entire dark theme. When the .dark-theme class is applied to an element (like the <body>), these new variable values will cascade down and override the :root defaults. Every element using var() will update instantly. It feels like magic.

Implementing a Theme Switcher with JavaScript

Our CSS is all set up and ready to go, but we need a way for the user to actually trigger the change. Time for a little bit of JavaScript.

Toggling Theme Classes on the <body> or <html>

Honestly, the simplest and cleanest method is to just toggle our .dark-theme class on the body or html element when a button is clicked.

Let's add a toggle button to our HTML:

<!-- index.html -->
<button id="theme-toggle">Toggle Theme</button>

And now the JavaScript to make it work:

// theme-switcher.js
const themeToggleBtn = document.getElementById('theme-toggle');
const body = document.body;

themeToggleBtn.addEventListener('click', () => {
  body.classList.toggle('dark-theme');
});

With just these few lines of code, you have a fully working theme switcher. Click the button, and watch the dark-theme class get added or removed, instantly changing the entire look of the page. So cool.

Updating Custom Properties with setProperty()

Toggling a class is my preferred method because it keeps the theme definitions purely in CSS, which feels right. However, sometimes you might want to update just a single variable directly from JavaScript. You can totally do that with the setProperty() method.

// Example: Change the primary color to red
document.documentElement.style.setProperty('--color-primary', 'red');

This could be super useful for something like a user-customizable UI where they can pick their own brand colors from a color picker.

Persisting User Preference with localStorage

There's nothing more annoying than setting a site to dark mode, refreshing the page, and being blinded by the light theme again. We can easily fix this by storing the user's choice in localStorage.

Here’s a slightly improved version of our switcher:

// theme-switcher.js (enhanced)
const themeToggleBtn = document.getElementById('theme-toggle');
const body = document.body;
const currentTheme = localStorage.getItem('theme');

// On page load, apply the saved theme
if (currentTheme) {
  body.classList.add(currentTheme);
}

themeToggleBtn.addEventListener('click', () => {
  body.classList.toggle('dark-theme');

  // Save the new theme preference
  let theme = 'light';
  if (body.classList.contains('dark-theme')) {
    theme = 'dark-theme';
  }
  localStorage.setItem('theme', theme);
});

Now, the user's choice will be remembered across sessions. This is one of those small details that makes a huge difference in user experience. For those of you working in component-based frameworks, this state logic is a perfect candidate for tools like React Hooks.

Respecting prefers-color-scheme

The final touch for a truly world-class theme switcher is to respect the user's operating system preference. Most modern operating systems have a system-wide light/dark mode setting, and we can actually detect this in the browser with the prefers-color-scheme media query.

// theme-switcher.js (final version)
const themeToggleBtn = document.getElementById('theme-toggle');
const body = document.body;

function applyTheme(theme) {
  body.classList.remove('dark-theme'); // Reset first
  if (theme === 'dark') {
    body.classList.add('dark-theme');
  }
}

function checkSystemPreference() {
  // Check localStorage first
  const savedTheme = localStorage.getItem('theme');
  if (savedTheme) {
    applyTheme(savedTheme);
    return;
  }

  // If no preference is saved, check the OS setting
  if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
    applyTheme('dark');
  } else {
    applyTheme('light');
  }
}

themeToggleBtn.addEventListener('click', () => {
  const isDark = body.classList.contains('dark-theme');
  const newTheme = isDark ? 'light' : 'dark';
  applyTheme(newTheme);
  localStorage.setItem('theme', newTheme);
});

// Listen for changes in OS preference
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', event => {
  const newColorScheme = event.matches ? "dark" : "light";
  // Only change if the user hasn't set a manual preference
  if (!localStorage.getItem('theme')) {
    applyTheme(newColorScheme);
  }
});


// Initial check
checkSystemPreference();

This logic is much more robust. It first checks for a user's explicit choice in localStorage. If it doesn't find one, it defaults to their OS setting. It's the best of both worlds. Perfect!

Advanced Theming Techniques

You've totally mastered the basics. Now let's see how deep this rabbit hole really goes.

Nested Themes and Component-Specific Variables

Remember how we talked about local scope? You can use that to create components that have their own, self-contained themes. For instance, a "primary action" section of your page could always be dark, even if the rest of the page is in light mode.

.primary-action-section {
  /* This section will always use the dark theme variables */
  --color-background: #121212;
  --color-surface: #1e1e1e;
  /* ...and so on */

  background-color: var(--color-background);
  color: var(--color-text-primary);
  padding: 2rem;
}

Theming Beyond Colors (Fonts, Spacing, Shadows, Gradients)

Don't just stop at colors! Literally anything that can be a CSS value can be stored in a variable.

:root {
  /* ... color variables */

  /* Fonts */
  --font-family-sans: 'Inter', sans-serif;
  --font-family-serif: 'Lora', serif;

  /* Spacing */
  --spacing-unit: 8px;
  --spacing-xs: var(--spacing-unit);      /* 8px */
  --spacing-sm: calc(var(--spacing-unit) * 2); /* 16px */
  --spacing-md: calc(var(--spacing-unit) * 3); /* 24px */

  /* Shadows */
  --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
  --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}

This kind of approach is the foundation of modern design token systems, where all stylistic values are abstracted into variables. It makes your design system incredibly consistent and so much easier to maintain.

Creating Multiple Theme Variants (e.g., High Contrast, Sepia)

Once you have this structure in place, adding more themes is almost trivial. All you have to do is define a new class with a new set of variable values.

.sepia-theme {
  --color-background: #f4e8d5;
  --color-surface: #f9f2e8;
  --color-text-primary: #5b4636;
  --color-primary: #8d6e63;
  --border-color: #d7ccc8;
}

.high-contrast-theme {
  --color-background: #000000;
  --color-text-primary: #ffffff;
  /* ... etc. */
}

Your JavaScript switcher would need a little more logic to cycle through the different themes, but the CSS part is really just this simple.

Accessibility Considerations (prefers-reduced-motion)

Just like prefers-color-scheme, we should also respect a user's preference for reduced motion. We can use a custom property to control our transitions and animations.

:root {
  --transition-duration: 0.3s;
}

@media (prefers-reduced-motion: reduce) {
  :root {
    --transition-duration: 0.01s;
  }
}

body {
  transition: background-color var(--transition-duration), color var(--transition-duration);
}

This is such a thoughtful touch that makes your site more accessible and comfortable for all users.

Best Practices for Theming

As your theme system grows, a few best practices will save you from some serious headaches down the road.

Naming Conventions and Organization

Try to develop a consistent naming scheme from the start. A common pattern I like is property-category-variant-state.

  • --color-primary-base
  • --color-primary-hover
  • --font-size-heading-lg
  • --spacing-inset-md

For larger projects, I'd also recommend splitting your theme variables into separate files. Having a _light-theme.css and a _dark-theme.css can make your codebase feel much cleaner and more organized.

Performance Tips

So, is there a performance cost to all this? The short answer is yes, but it's very, very small. The browser has to do a tiny bit more work to look up the variable values. In 99.9% of applications, this will never, ever be noticeable. Please don't prematurely optimize. The maintainability and feature benefits you get far outweigh the minuscule performance hit.

Debugging Custom Properties

This is one of my favorite parts. Modern browser developer tools have fantastic support for custom properties. In Chrome or Firefox, you can inspect an element, see all the custom properties it's inheriting, and even change their values in real-time to see what happens. It’s an incredibly fast and fun way to prototype and debug your themes.

Considerations for Large-Scale Applications

In a truly massive app, managing hundreds of variables can start to get tricky. At that point, you might consider:

  • A "theme" object in JavaScript: You could define your themes in JS objects and then use setProperty() to apply them. This centralizes all your theme logic in one place.
  • CSS-in-JS libraries: Libraries like Styled Components or Emotion have built-in theming providers that handle this stuff beautifully, often using custom properties under the hood. For complex component libraries, this is often the way to go. You can learn more about how to structure your projects in our other CSS tutorials.

Conclusion

We've gone from a simple idea—clicking a button to change some colors—all the way to a full-fledged, accessible, and persistent theming system. That, right there, is the power of CSS Custom Properties.

Key Takeaways

  • Custom Properties are "Live": Unlike Sass variables, they exist in the browser and can be changed at any time. This is the key to dynamic theming.
  • Scope is Your Superpower: Use global (:root) variables for your base theme and local variables for component-specific overrides.
  • JavaScript is the Conductor: Use JS to toggle a class on a parent element (<body>) to switch between themes defined in your CSS.
  • Always Persist and Respect User Choice: Use localStorage to remember a user's theme and prefers-color-scheme to set a sensible default.
  • Theme More Than Just Colors: Use variables for spacing, fonts, shadows—your entire design system.

The days of wrestling with stylesheets just to get theming to work are finally over. With CSS custom properties, you have a native, powerful, and elegant tool right at your fingertips. Now go build something beautiful!


Frequently Asked Questions

Can I still use Sass or another preprocessor with CSS Custom Properties? Absolutely! In fact, they work great together. I often use Sass for its powerful features like mixins, functions, and nesting, and then use CSS Custom Properties for any values that need to be dynamic in the browser. You can even set a custom property's value with a Sass variable: --color-primary: $my-brand-color;. It's the best of both worlds.

Are there any major performance concerns with using hundreds of CSS variables? For the vast majority of websites and applications, the simple answer is no. While there is a tiny bit of overhead compared to static values, modern browser engines are highly optimized for this. You would need to be updating thousands of variables many times per second before you'd even begin to notice an impact. My advice is to focus on the user experience and maintainability wins first.

What's the browser support like for CSS Custom Properties? Support is fantastic across all modern browsers, including Chrome, Firefox, Safari, and Edge. The only notable exception is Internet Explorer 11. If you're in a position where you no longer need to support IE11, you can use custom properties freely and with full confidence.

Related Articles