Theming
A theming system in Sass turns design tokens into a controlled set of values that components consume by name. Combine maps + CSS custom properties + media queries to ship dark mode, brand variants, or accessibility palettes without forking every stylesheet.
Tokens, CSS vars, dark mode, palettes
EXAMPLE
// 1) Tokens — the source of truth
// _tokens.scss
@use 'sass:map';
$light: (
'bg': #ffffff,
'surface': #f8fafc,
'text': #0f172a,
'text-muted': #475569,
'border': #e2e8f0,
'accent': #4f46e5,
'accent-fg': #ffffff,
'danger': #dc2626,
'success': #16a34a,
);
$dark: (
'bg': #0f172a,
'surface': #1e293b,
'text': #f1f5f9,
'text-muted': #94a3b8,
'border': #334155,
'accent': #818cf8,
'accent-fg': #0f172a,
'danger': #f87171,
'success': #4ade80,
);
$themes: (
'light': $light,
'dark': $dark,
);
// 2) Emit as CSS custom properties — one definition per theme
// _theme-output.scss
@use 'tokens';
@use 'sass:map';
@mixin theme-vars($map) {
@each $key, $value in $map {
--\#{$key}: \#{$value};
}
}
:root { @include theme-vars(map.get(tokens.$themes, 'light')); }
[data-theme="dark"] { @include theme-vars(map.get(tokens.$themes, 'dark')); }
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
@include theme-vars(map.get(tokens.$themes, 'dark'));
}
}
// 3) Components reference variables — never raw colors
.btn {
background: var(--accent);
color: var(--accent-fg);
border: 1px solid transparent;
border-radius: 0.5rem;
padding: 0.5rem 1rem;
transition: background-color 150ms ease;
}
.btn:hover { filter: brightness(1.05); }
.card {
background: var(--surface);
border: 1px solid var(--border);
color: var(--text);
}
// 4) Helper function with safe fallback
@function token($name, $fallback: null) {
@return var(--#{$name}, \#{$fallback});
}
.alert { background: token('danger', #ff0000); }
// 5) Theming with multiple brands
$brands: (
'default': ( 'accent': #4f46e5, 'accent-fg': #fff ),
'mint': ( 'accent': #10b981, 'accent-fg': #052e2b ),
'coral': ( 'accent': #fb7185, 'accent-fg': #1f0707 ),
);
@each $name, $overrides in $brands {
[data-brand="#{$name}"] {
@include theme-vars($overrides);
}
}
// HTML: <html data-brand="mint">…</html>
// 6) Dark mode toggle — the runtime piece (vanilla JS)
const applyTheme = (theme) => {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
};
const saved = localStorage.getItem('theme');
if (saved) applyTheme(saved);
document.getElementById('toggle')?.addEventListener('click', () => {
const cur = document.documentElement.getAttribute('data-theme') || 'light';
applyTheme(cur === 'light' ? 'dark' : 'light');
});
// 7) Density / size scales also tokenisable
$density: (
'compact': ( 'space-1': 0.25rem, 'space-2': 0.5rem, 'space-3': 0.75rem ),
'cozy': ( 'space-1': 0.5rem, 'space-2': 1rem, 'space-3': 1.5rem ),
);
@each $name, $tokens in $density {
[data-density="#{$name}"] { @include theme-vars($tokens); }
}
// 8) High-contrast accessibility theme
$hc: (
'bg': #000, 'text': #fff,
'border': #fff, 'accent': #ffd400, 'accent-fg': #000,
);
@media (prefers-contrast: more) {
:root { @include theme-vars($hc); }
}
// 9) Color manipulation — pre-compute or use CSS color-mix()
// At build time (Sass):
@use 'sass:color';
$accent-50: color.scale(map.get($light, 'accent'), $lightness: 80%);
$accent-700: color.scale(map.get($light, 'accent'), $lightness: -20%);
// At runtime (CSS):
.btn:hover {
background: color-mix(in srgb, var(--accent), white 10%);
}
.btn:active {
background: color-mix(in srgb, var(--accent), black 10%);
}
// 10) Forwarding tokens to consumers (library pattern)
// _public.scss — what other people use
@forward 'tokens';
@forward 'theme-output';
@forward 'components';
// Consumer
// @use '@my-org/ui' as ui;
// .my-btn { background: var(--accent); }
// 11) Auditing — find raw color values that snuck in
// grep + ripgrep for #aabbcc / rgba(
// rg --type css '#[0-9a-fA-F]{3,8}\\b' src/
// rg --type css 'rgba?\\(' src/
// Goal: 0 raw colors in component files; every visible color resolves to a token.
// 12) Tokens for typography + radii + shadows
$base: (
'font-base': 'Inter, system-ui, sans-serif',
'font-mono': 'JetBrains Mono, monospace',
'text-sm': 0.875rem,
'text-base': 1rem,
'text-lg': 1.125rem,
'radius-sm': 0.25rem,
'radius-md': 0.5rem,
'shadow-sm': '0 1px 2px rgba(0,0,0,0.04)',
'shadow-md': '0 4px 12px rgba(0,0,0,0.08)',
);
:root { @include theme-vars($base); }
body { font-family: var(--font-base); font-size: var(--text-base); }
code { font-family: var(--font-mono); }
// 13) Build-step considerations
// • Dart Sass + 'modules' system (@use/@forward) — never @import
// • Run css-vars-ponyfill ONLY if you need IE11 support; modern browsers do this natively
// • PurgeCSS / Tailwind safelist: keep theme attribute selectors in the allowlist
// • Test in both themes; visually compare with a screenshot tool (Storybook + Chromatic)
// 14) Common bugs
// • Toggling theme by changing class on <body> instead of <html> — some selectors miss it
// • CSS variables defined AFTER they're used → 'invalid property value' fallback
// • Forgetting prefers-color-scheme override → user with OS dark mode sees light
// • Hard-coded color in a media query — won't theme; convert to var(--…)
// • Long token names like '--brand-button-primary-hover-bg' — favour layered tokens
// (semantic name → palette name → color value) to avoid combinatorial explosion
// • Calculations that need numeric color channels — use 'r g b' tokens + 'rgb(var(--accent-rgb) / 0.5)'
// patterns so opacity is composable
Why it matters
Build theming as layered tokens: define every brand-relevant value once in a Sass map, emit them as CSS custom properties per theme attribute, and reference those variables — never raw colours — in component styles. Honour prefers-color-scheme and prefers-contrast so users get the theme they’ve already configured at the OS level.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Discussion
Loading…