A Theming System with Maps + CSS Custom Properties
Marry Sass maps (build-time structure) with CSS custom properties (runtime switching) for real themes.
Here is the single most important idea in modern Sass architecture: Sass maps give you structure at build time; CSS custom properties give you switching at runtime. Use both. Sass loops over your token map and emits custom properties; your components read those custom properties, never the Sass variables directly.
Why not Sass variables alone?
Sass variables vanish at compile time, so a .dark class cannot change them in the browser. Custom properties cascade and can be overridden per-scope — which is exactly what a theme toggle needs.
:root { --brand: #CC6699; }
.dark { --brand: #E29CBC; }
.btn { background: var(--brand); } // follows the theme liveThe pattern
- Define themes as Sass maps (structured, validated).
- Loop with
@eachto emit--custom-propsper theme scope. - Components consume only
var(--token)— theme-agnostic by construction.
Example
@use 'sass:map';
// 1) Themes as structured maps
$themes: (
'light': ('bg': #ffffff, 'fg': #111827, 'brand': #CC6699),
'dark': ('bg': #0b0f19, 'fg': #e5e7eb, 'brand': #E29CBC),
);
// 2) Emit custom properties per theme scope
@each $name, $tokens in $themes {
$selector: if($name == 'light', ':root', '[data-theme="#{$name}"]');
#{$selector} {
@each $key, $value in $tokens {
--#{$key}: #{$value};
}
}
}
// 3) Components read var() only — theme-agnostic
body { background: var(--bg); color: var(--fg); }
.btn {
background: var(--brand, #{map.get(map.get($themes, 'light'), 'brand')});
color: #fff;
}
// Toggle by setting data-theme="dark" on <html> — no recompile.When to use it
- A SaaS dashboard stores both light and dark design tokens in Sass maps and emits them as CSS custom properties so a data-theme attribute switch requires zero JavaScript style manipulation.
- A multi-tenant platform generates a separate compiled CSS file per brand by swapping a single Sass token map and recompiling, enabling true white-labelling without runtime overhead.
- A developer co-locates a component's dark-mode overrides right next to its base styles by wrapping them in a theme() mixin that emits under the correct [data-theme] selector.
More examples
Emit theme tokens as custom properties
A single mixin loops over any theme map and emits every key as a CSS custom property, keeping the switch to dark mode a one-attribute change.
@use 'sass:map';
$light: (bg: #ffffff, text: #111827, surface: #f9fafb, border: #e5e7eb);
$dark: (bg: #0f172a, text: #f1f5f9, surface: #1e293b, border: #334155);
@mixin emit-theme($map) {
@each $k, $v in $map { --#{$k}: #{$v}; }
}
:root { @include emit-theme($light); }
[data-theme=dark] { @include emit-theme($dark); }Component referencing theme tokens
The component uses CSS custom properties from the theme system so it automatically reflects the active theme with no extra CSS.
.card {
background: var(--surface);
color: var(--text);
border: 1px solid var(--border);
padding: 1.5rem;
transition: background 0.2s ease, color 0.2s ease;
}Per-brand theme compilation
Each brand entry file passes a different token map to the shared theme module, producing independent compiled files for white-labelling.
// brand-red/main.scss
$theme: (primary: #dc2626, bg: #fff5f5, text: #1a1a1a);
@use '../shared/theme-system' with ($tokens: $theme);
// brand-blue/main.scss
$theme: (primary: #2563eb, bg: #eff6ff, text: #1e3a5f);
@use '../shared/theme-system' with ($tokens: $theme);
// Each compiles to a separate .css file.
Discussion