DEV Community

Cover image for SCSS Maps + CSS Custom Properties: Scalable Runtime Theming Without Duplication
Olexandr Uvarov
Olexandr Uvarov

Posted on

SCSS Maps + CSS Custom Properties: Scalable Runtime Theming Without Duplication

You're building a platform that serves different user segments — each with their own visual style. Maybe it's A/B testing different UIs, maybe it's a white-label product with per-client branding. You have 6 modes today: "Pink Big", "Blue Small", "Dark Compact" and so on. Next month the design team rewrites half of them.

How do you organize your styles so that adding or changing a mode doesn't mean editing every component file?

I've been exploring different approaches in a side project and landed on a pattern that combines the best of SCSS maps and CSS custom properties. Let me walk you through the journey.

The Problem

We need to:

  • Switch between multiple visual modes at runtime based on user config or API response
  • Keep the bundle size reasonable
  • Make it easy to add, remove, or modify modes without touching every component
  • Keep component styles readable

Let's say we have 6 modes and 30 components that depend on them. That's our scale. Let's explore three approaches.

Approach 1: Pure SCSS Maps + Mixin

SCSS maps let you store structured data — essentially objects in your stylesheets. The idea: define all modes in one place and use a mixin to apply them.

$modes: (
  pink-big: (
    color: #ff69b4,
    font-size: 32px,
    font-weight: 700,
  ),
  blue-small: (
    color: #4a90d9,
    font-size: 18px,
    font-weight: 400,
  ),
);

@mixin apply-modes($component) {
  @each $mode-name, $components in $modes {
    .mode-#{$mode-name} & {
      $styles: map-get($components, $component);
      @each $prop, $value in $styles {
        #{$prop}: $value;
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Usage looks clean:

.title {
  @include apply-modes(title);
}

.button {
  @include apply-modes(button);
}
Enter fullscreen mode Exit fullscreen mode

The problem: bundle bloat

Look at what this compiles to:

.mode-pink-big .title {
  color: #ff69b4;
  font-size: 32px;
  font-weight: 700;
}
.mode-blue-small .title {
  color: #4a90d9;
  font-size: 18px;
  font-weight: 400;
}
.mode-pink-big .button {
  color: #ff69b4;
  font-size: 32px;
  font-weight: 700;
}
.mode-blue-small .button {
  color: #4a90d9;
  font-size: 32px;
  font-weight: 700;
}
Enter fullscreen mode Exit fullscreen mode

Every component gets a full copy of every mode's styles. With 6 modes and 30 components, that's 180 style blocks. With just 2 modes and 30 components, that would be 60. The bundle grows as modes × components — and that adds up fast.

Approach 2: Pure CSS Custom Properties

CSS custom properties solve the duplication problem elegantly. Define variables once per mode, use them everywhere:

.mode-pink-big {
  --color-primary: #ff69b4;
  --fs-title: 32px;
  --fw-title: 700;
}

.mode-blue-small {
  --color-primary: #4a90d9;
  --fs-title: 18px;
  --fw-title: 400;
}

.title {
  color: var(--color-primary);
  font-size: var(--fs-title);
  font-weight: var(--fw-title);
}

.button {
  background: var(--color-primary);
}
Enter fullscreen mode Exit fullscreen mode

The math changes completely: 6 mode definitions + 30 components = 36 blocks instead of 180. Bundle grows as modes + components.

Switching modes at runtime is just swapping a class:

function App() {
  const userMode = useUserMode(); // from API, config, etc.

  return (




  );
}
Enter fullscreen mode Exit fullscreen mode

The problem: maintenance at scale

With 6 modes, you're writing variable declarations by hand:

.mode-pink-big { --color-primary: #ff69b4; --fs-title: 32px; --fw-title: 700; }
.mode-blue-small { --color-primary: #4a90d9; --fs-title: 18px; --fw-title: 400; }
.mode-dark-compact { --color-primary: #bb86fc; --fs-title: 16px; --fw-title: 500; }
.mode-light-wide { --color-primary: #1a73e8; --fs-title: 28px; --fw-title: 600; }
/* ...and two more */
Enter fullscreen mode Exit fullscreen mode

It's flat, scattered, and easy to forget a variable when adding a new mode. There's no single source of truth you can glance at and understand all your modes.

Approach 3: The Combo — SCSS Maps Generating CSS Custom Properties

Here's where it gets interesting. What if we use SCSS maps as a structured config and generate CSS custom properties from them automatically?

Step 1: Define modes as nested SCSS maps

$modes: (
  pink-big: (
    title: (color: #ff69b4, font-size: 32px, font-weight: 700),
    button: (background: #ff69b4, padding: 16px, border-radius: 12px),
  ),
  blue-small: (
    title: (color: #4a90d9, font-size: 18px, font-weight: 400),
    button: (background: #4a90d9, padding: 8px, border-radius: 4px),
  ),
  dark-compact: (
    title: (color: #bb86fc, font-size: 16px, font-weight: 500),
    button: (background: #bb86fc, padding: 6px, border-radius: 4px),
  ),
);
Enter fullscreen mode Exit fullscreen mode

Everything in one place. You can see all modes, all components, all values at a glance.

Step 2: Generate CSS custom properties automatically

@each $mode-name, $components in $modes {
  .mode-#{$mode-name} {
    @each $component, $styles in $components {
      @each $prop, $value in $styles {
        --#{$component}-#{$prop}: #{$value};
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

This compiles to:

.mode-pink-big {
  --title-color: #ff69b4;
  --title-font-size: 32px;
  --title-font-weight: 700;
  --button-background: #ff69b4;
  --button-padding: 16px;
  --button-border-radius: 12px;
}
.mode-blue-small {
  --title-color: #4a90d9;
  --title-font-size: 18px;
  /* ...etc */
}
Enter fullscreen mode Exit fullscreen mode

Step 3: A mixin to wire components to their variables

Instead of manually writing var(--title-color), var(--title-font-size) for each property, let a mixin do it:

@mixin use-mode($component) {
  $first-mode: nth(map-values($modes), 1);
  $styles: map-get($first-mode, $component);

  @each $prop, $_ in $styles {
    #{$prop}: var(--#{$component}-#{$prop});
  }
}
Enter fullscreen mode Exit fullscreen mode

Step 4: Use it

.title {
  @include use-mode(title);
  margin-bottom: 16px; // static styles work alongside
  text-transform: uppercase;
}

.button {
  @include use-mode(button);
  cursor: pointer;
}
Enter fullscreen mode Exit fullscreen mode

This compiles to:

.title {
  color: var(--title-color);
  font-size: var(--title-font-size);
  font-weight: var(--title-font-weight);
  margin-bottom: 16px;
  text-transform: uppercase;
}

.button {
  background: var(--button-background);
  padding: var(--button-padding);
  border-radius: var(--button-border-radius);
  cursor: pointer;
}
Enter fullscreen mode Exit fullscreen mode

One line to connect a component to its mode tokens. Static styles coexist naturally. No duplication.

Build-Time Validation

When someone adds a new mode but forgets a property, you want the build to fail — not the UI:

$required-keys: (
  title: (color, font-size, font-weight),
  button: (background, padding, border-radius),
);

@each $mode-name, $components in $modes {
  @each $component, $required in $required-keys {
    $styles: map-get($components, $component);
    @each $key in $required {
      @if not map-has-key($styles, $key) {
        @error "Mode '#{$mode-name}' → '#{$component}' is missing '#{$key}'";
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Add a mode with a missing font-weight? Build fails with:

Error: Mode 'new-mode' → 'title' is missing 'font-weight'
Enter fullscreen mode Exit fullscreen mode

Much better than debugging a broken layout in production.

What About Dynamic Styles from a CMS?

So far we've assumed all modes are known at build time. But what if styles come from a CMS like Strapi, and you don't know the values until runtime?

The tempting (bad) approach

Fetch styles from the CMS and apply them directly to elements:

const tokens = await fetchStylesFromCMS();

document.querySelectorAll('.title').forEach(el => {
  el.style.color = tokens.titleColor;
  el.style.fontSize = tokens.titleFontSize;
  el.style.fontWeight = tokens.titleFontWeight;
});
Enter fullscreen mode Exit fullscreen mode

Each el.style.* assignment can trigger a reflow. With 30 elements and 3 properties each, you're looking at up to 90 potential layout recalculations. Plus you lose the cascade, specificity becomes unpredictable, and debugging inline styles in DevTools is no fun.

The right approach: same CSS variables, different source

const tokens = await fetchStylesFromCMS();

Object.entries(tokens).forEach(([key, value]) => {
  document.documentElement.style.setProperty(`--${key}`, value);
});
Enter fullscreen mode Exit fullscreen mode

One update on :root, browser recalculates the cascade once. Components don't care where the value came from.

The hybrid: build-time defaults + CMS overrides

This is where it gets practical. Your SCSS maps define sensible defaults per mode, and the CMS can override specific tokens at runtime:

// Build time — mode defaults baked in
.mode-pink-big {
  --title-color: #ff69b4;
  --title-font-size: 32px;
}
Enter fullscreen mode Exit fullscreen mode
// Runtime — CMS overrides only what it needs
const cmsOverrides = await fetchStylesFromCMS();

if (cmsOverrides.titleColor) {
  document.documentElement.style.setProperty('--title-color', cmsOverrides.titleColor);
}
// If CMS sends nothing — mode default stays
Enter fullscreen mode Exit fullscreen mode

The CSS cascade handles the priority naturally: inline custom properties on :root override class-based ones. Your component still just reads var(--title-color) — it doesn't know or care whether the value came from SCSS, a class, or JavaScript.

Why Not @extend?

You might think: "I'll use SCSS @extend with a placeholder for each mode." The problem is that @extend was designed to merge selectors in a global stylesheet:

// Global SCSS — @extend works as intended
%pink-title {
  color: #ff69b4;
  font-size: 32px;
}

.title { @extend %pink-title; }
.hero-title { @extend %pink-title; }
Enter fullscreen mode Exit fullscreen mode

Compiles to one merged selector:

.title, .hero-title {
  color: #ff69b4;
  font-size: 32px;
}
Enter fullscreen mode Exit fullscreen mode

But almost nobody writes global SCSS anymore. With CSS Modules — which is the standard in React/Next.js projects — @extend loses its superpower. CSS Modules scope each file independently, so there's no opportunity to merge selectors across modules. The result? @extend just copies the properties into each selector separately — exactly like a mixin, but with less control and more confusion. You get the same duplication problem from Approach 1, just with different syntax.

CSS custom properties don't have this issue. They cascade through scoped modules naturally — no cross-module dependencies needed.

Wrapping Up

The pattern is: SCSS maps as a structured config → CSS custom properties as the runtime mechanism. SCSS does the heavy lifting at build time (generating variables, validating completeness), and CSS custom properties handle the runtime switching with zero duplication.

It works for predefined modes, for CMS-driven styles, and for hybrids of both. Adding a mode is one entry in $modes. Adding a CMS override is one setProperty call. Components never need to change.

The trade-off is readability — @include use-mode(title) hides which properties a component receives. But when your modes change every month and you're tired of hunting down missed variables across 30 files, that trade-off pays for itself.


*What patterns do you use for runtime theming? I'd love to hear about different approaches in the comments.

Top comments (0)