iwantcoding.com
🔥 Daily 👥 Rooms 🏆 Top Log in Sign up

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.

Example

Example
@use 'theme' with ($primary: #04AA6D);
Try it Yourself »

Discussion

Loading…