The pain this guide solves
Your _variables.scss has 80 hardcoded color values someone added over three years. Half of them are never used. Dark mode is a separate _dark.scss that nobody updates. Changing the primary color means grepping through a dozen partials and hoping nothing was hardcoded outside the variables file.
salt-theme-gen with Sass/SCSS
Two approaches
Both work. Use whichever fits your project:
| Approach | How | Best for |
|---|---|---|
| CSS variables (recommended) | Generate a .css file, import in Sass | Any project — Sass can use var() |
| Sass variables | Generate a _tokens.scss partial, import in Sass | Projects that need Sass $variable syntax |
The CSS variable approach is simpler and gives you dark mode for free. The Sass variable approach generates $primary, $spacing-md, etc. — familiar to teams that work heavily in Sass.
Approach 1 — CSS variables with Sass (recommended)
Generate a theme.css file from the token script (see the Vanilla JS guide) and import it in your main Sass file:
// src/main.scss
@use 'sass:math';
// Import generated CSS variables
@import './theme.css'; // or link it in <head>
// Your Sass — references CSS vars
body {
background-color: var(--color-background);
color: var(--color-text);
font-size: var(--text-md);
font-family: system-ui, -apple-system, sans-serif;
line-height: 1.6;
}
.btn {
background: var(--color-primary);
color: var(--color-on-primary);
border: none;
border-radius: var(--radius-md);
padding: var(--space-sm) var(--space-lg);
font-size: var(--text-md);
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
&:hover { background: var(--state-primary-hover); }
&:active { background: var(--state-primary-pressed); }
&:focus-visible {
outline: 2px solid var(--state-primary-focused);
outline-offset: 2px;
}
&:disabled {
background: var(--state-primary-disabled);
cursor: not-allowed;
}
}
CSS variables are first-class in Sass — you can use them anywhere you would use a hardcoded value. Dark mode works the same way as in any other framework: [data-theme="dark"] or @media (prefers-color-scheme: dark) in the generated CSS file handles the switch.
Approach 2 — Generated Sass variables
For teams that prefer $primary over var(--color-primary), generate a _tokens.scss partial at build time.
Generation script
Create scripts/generate-sass-tokens.mjs:
import { generateTheme } from 'salt-theme-gen';
import { writeFileSync, mkdirSync } from 'node:fs';
const theme = generateTheme({
preset: 'ocean',
spacing: 'default',
radius: 'default',
fontSize: 'default',
});
function kebab(str) {
return str.replace(/([A-Z])/g, '-$1').toLowerCase();
}
function sassValue(val) {
// Keep OKLCH as-is for modern Sass + browsers that support it
// Or convert to hex if you need wider browser support
return val;
}
function colorsBlock(mode, prefix) {
const lines = [`// ${prefix} colors`];
for (const [k, v] of Object.entries(mode.colors))
lines.push(`$${prefix}-${kebab(k)}: ${sassValue(v)};`);
return lines.join('\n');
}
function statesBlock(mode, prefix) {
const lines = [`\n// ${prefix} states`];
for (const [intent, states] of Object.entries(mode.states))
for (const [state, val] of Object.entries(states))
lines.push(`$${prefix}-state-${intent}-${state}: ${sassValue(val)};`);
return lines.join('\n');
}
function scalesBlock(mode) {
const spacing = Object.entries(mode.spacing)
.map(([k, v]) => `$spacing-${k}: ${v}px;`)
.join('\n');
const radius = Object.entries(mode.radius)
.map(([k, v]) => `$radius-${k}: ${v}px;`)
.join('\n');
const fontSizes = Object.entries(mode.fontSizes)
.map(([k, v]) => `$font-${k}: ${v}px;`)
.join('\n');
const surfaceElevation = Object.entries(mode.surfaceElevation)
.map(([k, v]) => `$surface-${k}: ${sassValue(v)};`)
.join('\n');
return [
'// Spacing',
spacing,
'\n// Radius',
radius,
'\n// Font sizes',
fontSizes,
'\n// Surface elevation',
surfaceElevation,
].join('\n');
}
// Build Sass map for color iteration
function colorsMap(mode, name) {
const entries = Object.entries(mode.colors)
.map(([k, v]) => ` '${kebab(k)}': ${sassValue(v)}`)
.join(',\n');
return `$${name}: (\n${entries}\n);`;
}
const sass = [
'// Generated by salt-theme-gen — do not edit manually',
'// Run: node scripts/generate-sass-tokens.mjs',
'',
colorsBlock(theme.light, 'light'),
statesBlock(theme.light, 'light'),
'',
colorsBlock(theme.dark, 'dark'),
statesBlock(theme.dark, 'dark'),
'',
scalesBlock(theme.light),
'',
'// Sass maps (for iteration)',
colorsMap(theme.light, 'light-colors'),
'',
colorsMap(theme.dark, 'dark-colors'),
].join('\n');
mkdirSync('src/styles', { recursive: true });
writeFileSync('src/styles/_tokens.scss', sass, 'utf8');
console.log('✓ src/styles/_tokens.scss written');
Run it:
node scripts/generate-sass-tokens.mjs
Add to package.json:
{
"scripts": {
"build:tokens": "node scripts/generate-sass-tokens.mjs",
"build": "npm run build:tokens && vite build",
"dev": "npm run build:tokens && vite dev"
}
}
Using generated Sass variables
Import and use
// src/styles/main.scss
@use './tokens' as t;
body {
background-color: t.$light-background;
color: t.$light-text;
font-size: t.$font-md;
font-family: system-ui, -apple-system, sans-serif;
}
.btn-primary {
background: t.$light-primary;
color: t.$light-on-primary;
border: none;
border-radius: t.$radius-md;
padding: t.$spacing-sm t.$spacing-lg;
&:hover { background: t.$light-state-primary-hover; }
}
Dark mode with Sass variables
With Sass variables, you handle dark mode with a class or data attribute selector:
@use './tokens' as t;
:root {
--color-background: #{t.$light-background};
--color-primary: #{t.$light-primary};
--color-text: #{t.$light-text};
// ... all tokens as CSS vars using Sass values
}
[data-theme='dark'] {
--color-background: #{t.$dark-background};
--color-primary: #{t.$dark-primary};
--color-text: #{t.$dark-text};
}
body {
background-color: var(--color-background);
color: var(--color-text);
}
This pattern uses Sass variables to populate CSS custom properties — you get the best of both worlds. Sass variables for the generation source of truth, CSS variables for runtime dark mode switching.
Sass maps for iteration
The generated $light-colors and $dark-colors maps let you generate utility classes programmatically:
@use './tokens' as t;
// Generate .text-{color} and .bg-{color} for each intent color
@each $name, $value in t.$light-colors {
.text-#{$name} { color: $value; }
.bg-#{$name} { background-color: $value; }
}
// Generate .p-{size} spacing utilities
$spacing-map: (
'xs': t.$spacing-xs,
'sm': t.$spacing-sm,
'md': t.$spacing-md,
'lg': t.$spacing-lg,
'xl': t.$spacing-xl,
'xxl': t.$spacing-xxl,
);
@each $name, $value in $spacing-map {
.p-#{$name} { padding: $value; }
.px-#{$name} { padding-left: $value; padding-right: $value; }
.py-#{$name} { padding-top: $value; padding-bottom: $value; }
.gap-#{$name}{ gap: $value; }
}
Component patterns with Sass
Button with all states
// components/_button.scss
@use '../styles/tokens' as t;
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
cursor: pointer;
font-weight: 600;
transition: background 0.15s;
border-radius: t.$radius-md;
font-size: t.$font-md;
padding: t.$spacing-sm t.$spacing-lg;
// Intent variants
&--primary {
background: t.$light-primary;
color: t.$light-on-primary;
&:hover { background: t.$light-state-primary-hover; }
&:active { background: t.$light-state-primary-pressed; }
&:focus-visible {
outline: 2px solid t.$light-state-primary-focused;
outline-offset: 2px;
}
&:disabled {
background: t.$light-state-primary-disabled;
cursor: not-allowed;
}
}
&--danger {
background: t.$light-danger;
color: t.$light-on-danger;
&:hover { background: t.$light-state-danger-hover; }
}
&--success {
background: t.$light-success;
color: t.$light-on-success;
&:hover { background: t.$light-state-success-hover; }
}
}
[data-theme='dark'] {
.btn--primary {
background: t.$dark-primary;
color: t.$dark-on-primary;
&:hover { background: t.$dark-state-primary-hover; }
}
}
Card
// components/_card.scss
@use '../styles/tokens' as t;
.card {
background: t.$light-surface-card; // surfaceElevation.card
border: 1px solid t.$light-border;
border-radius: t.$radius-lg;
padding: t.$spacing-xl;
&__title {
font-size: t.$font-lg;
font-weight: 700;
color: t.$light-text;
margin: 0 0 t.$spacing-sm;
}
&__body {
font-size: t.$font-md;
color: t.$light-muted;
line-height: 1.7;
margin: 0;
}
}
[data-theme='dark'] .card {
background: t.$dark-surface-card;
border-color: t.$dark-border;
&__title { color: t.$dark-text; }
&__body { color: t.$dark-muted; }
}
Using with Vite
Vite’s built-in Sass support requires no additional configuration for this pattern. Install Sass:
npm install --save-dev sass
Import your main Sass file in main.ts or main.js:
import './src/styles/main.scss';
Vite compiles Sass automatically.
Auto-inject tokens in every file
To make _tokens.scss available in every Sass file without explicit @use:
// vite.config.ts
import { defineConfig } from 'vite';
export default defineConfig({
css: {
preprocessorOptions: {
scss: {
additionalData: `@use '/src/styles/tokens' as t;`,
},
},
},
});
With additionalData, every .scss file automatically has access to t.$light-primary, t.$spacing-md, etc. — no @use needed in individual component files.
File structure summary
scripts/
└── generate-sass-tokens.mjs ← writes _tokens.scss
src/
└── styles/
├── _tokens.scss ← generated — do not edit
├── main.scss ← imports tokens + base styles
└── components/
├── _button.scss
└── _card.scss
Checklist
node scripts/generate-sass-tokens.mjswrites_tokens.scss✓package.jsonprebuild/predevruns token generation ✓- Component Sass files use
@use '../styles/tokens' as t✓ vite.config.tsinjects tokens viaadditionalData(optional) ✓- No hardcoded color values in any Sass file ✓
[data-theme='dark']block updates all dark mode values ✓
Recommended: For most Sass projects, use the CSS variable approach (Approach 1) rather than Sass variables. CSS variables give you runtime dark mode switching with zero JavaScript — just toggle data-theme on <html>. Sass variables require re-generating styles or duplicating selectors for dark mode, which adds maintenance cost.
Live demo
Open this integration in StackBlitz — fully working, editable in your browser.
Open in StackBlitz →