The pain this guide solves
You are building a static site, a browser extension, or a simple multi-page app with no framework. Every design token guide assumes React or Vue. You just want the CSS variables in a stylesheet — nothing more.
salt-theme-gen with Vanilla JS
What you will build
- A Node.js build script that generates a
theme.cssfile fromgenerateTheme()andgenerateCssVariables() - 150+
--salt-*custom properties covering colors, palettes, states, spacing, radius, and font sizes - A dark mode toggle in ~15 lines of JavaScript
@supports (color: oklch(...))progressive enhancement — hex fallback for older browsers, oklch for modern ones
Time required: 10 minutes.
Two approaches
| Build script | Runtime inject | |
|---|---|---|
| How | Node script → writes theme.css | <script> → injects <style> |
| Runtime JS | Zero | ~10 lines |
| Dark mode FOUC | None (with inline script) | None (if script is synchronous) |
| Best for | Static sites, CI pipelines, any server | SPAs, browser extensions |
Both are covered below.
Approach 1 — Build script (recommended)
Install
npm install salt-theme-gen
Generate script
Create scripts/build-theme.mjs:
import { generateTheme, generateCssVariables } from 'salt-theme-gen';
import { writeFileSync, mkdirSync } from 'node:fs';
const theme = generateTheme({
preset: 'ocean',
spacing: 'default',
radius: 'default',
fontSize: 'default',
});
// format: 'both' → hex fallback + @supports oklch block for modern browsers
const { css, dark } = generateCssVariables(theme, {
format: 'both',
lightSelector: ':root',
darkSelector: '[data-theme="dark"]',
});
// Also support @media prefers-color-scheme alongside the data-theme toggle
const mediaBlock = `\n@media (prefers-color-scheme: dark) {\n :root:not([data-theme="light"]) {\n${dark.split('\n').map(l => ' ' + l).join('\n')}\n }\n}`;
const outPath = 'public/theme.css';
mkdirSync('public', { recursive: true });
writeFileSync(outPath, `/* Generated by salt-theme-gen — do not edit manually */\n\n${css}${mediaBlock}\n`, 'utf8');
console.log(`✓ theme.css written (${(css.length / 1024).toFixed(1)} KB)`);
Run it
node scripts/build-theme.mjs
Add to package.json so it runs before every build:
{
"scripts": {
"build:theme": "node scripts/build-theme.mjs",
"build": "npm run build:theme && your-other-build-command"
}
}
Use in HTML
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>My Site</title>
<!-- Theme variables — generated at build time -->
<link rel="stylesheet" href="/theme.css" />
<!-- Apply saved preference before first paint (prevents FOUC) -->
<script>
(function () {
var t = localStorage.getItem('theme');
if (t === 'dark' || t === 'light')
document.documentElement.setAttribute('data-theme', t);
}());
</script>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<!-- Your content -->
</body>
</html>
The inline script is synchronous — it sets data-theme before the browser paints, preventing any flash.
Approach 2 — Runtime injection
No build step. Use generateCssVariables() directly in the browser via a module script:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>My Site</title>
<!-- Apply saved preference before theme injects -->
<script>
(function () {
var t = localStorage.getItem('theme');
if (t === 'dark' || t === 'light')
document.documentElement.setAttribute('data-theme', t);
}());
</script>
<script type="module">
import { generateTheme, generateCssVariables } from 'https://esm.sh/salt-theme-gen@1.3.0';
const theme = generateTheme({ preset: 'ocean' });
const { css } = generateCssVariables(theme, { format: 'both' });
const style = document.createElement('style');
style.id = 'salt-theme';
style.textContent = css;
document.head.prepend(style);
</script>
</head>
</html>
esm.sh converts npm packages to ES modules — no bundler needed. prepend ensures your own overrides always win.
Dark mode toggle
<button id="theme-toggle" aria-label="Toggle dark mode">◐</button>
<script>
const btn = document.getElementById('theme-toggle');
const html = document.documentElement;
function getResolvedMode() {
const saved = html.getAttribute('data-theme');
if (saved === 'dark' || saved === 'light') return saved;
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
function setMode(mode) {
html.setAttribute('data-theme', mode);
localStorage.setItem('theme', mode);
btn.textContent = mode === 'dark' ? '☀' : '◐';
}
btn.textContent = getResolvedMode() === 'dark' ? '☀' : '◐';
btn.addEventListener('click', () => {
setMode(getResolvedMode() === 'dark' ? 'light' : 'dark');
});
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
if (!localStorage.getItem('theme')) setMode(e.matches ? 'dark' : 'light');
});
</script>
What the generated CSS looks like
generateCssVariables with format: 'both' produces three blocks:
/* 1. Main block — hex colors + all tokens */
:root {
--salt-color-primary: #0077dc;
--salt-color-secondary: #00789a;
--salt-color-background: #f5f8ff;
--salt-color-surface: #ffffff;
--salt-color-text: #111827;
--salt-color-muted: #6b7280;
--salt-color-border: #d1d5db;
--salt-color-on-primary: #ffffff;
/* ...18 more semantic colors */
--salt-palette-primary-50: #f0f6ff;
--salt-palette-primary-500: #3a7ec8;
--salt-palette-primary-950: #060f28;
/* ...85 more palette steps */
--salt-surface-card: #ffffff;
--salt-surface-modal: #f8fbff;
/* ...2 more elevation levels */
--salt-state-primary-hover: #0068c4;
--salt-state-primary-focused: #5aacff;
/* ...30 more state colors */
--salt-spacing-xs: 4px;
--salt-spacing-md: 12px;
--salt-spacing-xxl: 32px;
/* ...4 more spacing steps */
--salt-radius-md: 10px;
--salt-radius-pill: 9999px;
/* ...5 more radius steps */
--salt-font-size-md: 16px;
/* ...6 more font sizes */
}
/* 2. Dark mode overrides */
[data-theme="dark"] {
--salt-color-primary: #459af9;
--salt-color-background: #0e1724;
/* ...all color vars at dark values */
}
/* 3. oklch override — modern browsers only */
@supports (color: oklch(0 0 0)) {
:root {
--salt-color-primary: oklch(57.07% 0.1776 253.29);
/* ...all color vars in oklch */
}
[data-theme="dark"] {
--salt-color-primary: oklch(67.90% 0.1629 253.44);
}
}
150+ variables in total. Older browsers get hex, modern browsers get oklch — automatically, with no JavaScript required at runtime.
Using the variables in plain CSS
/* styles.css */
*, *::before, *::after { box-sizing: border-box; }
body {
margin: 0;
background-color: var(--salt-color-background);
color: var(--salt-color-text);
font-size: var(--salt-font-size-md);
font-family: system-ui, -apple-system, sans-serif;
line-height: 1.6;
transition: background-color 0.2s, color 0.2s;
}
/* Navigation */
.nav {
background: var(--salt-color-surface);
border-bottom: 1px solid var(--salt-color-border);
padding: var(--salt-spacing-md) var(--salt-spacing-xl);
}
/* Buttons */
.btn {
border: none;
border-radius: var(--salt-radius-md);
padding: var(--salt-spacing-sm) var(--salt-spacing-lg);
font-size: var(--salt-font-size-md);
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
}
.btn-primary {
background: var(--salt-color-primary);
color: var(--salt-color-on-primary);
}
.btn-primary:hover { background: var(--salt-state-primary-hover); }
.btn-primary:active { background: var(--salt-state-primary-pressed); }
.btn-primary:focus-visible {
outline: 2px solid var(--salt-state-primary-focused);
outline-offset: 2px;
}
.btn-primary:disabled {
background: var(--salt-state-primary-disabled);
cursor: not-allowed;
}
/* Cards */
.card {
background: var(--salt-surface-card);
border: 1px solid var(--salt-color-border);
border-radius: var(--salt-radius-lg);
padding: var(--salt-spacing-xl);
}
/* Alerts */
.alert { border-radius: var(--salt-radius-md); padding: var(--salt-spacing-md) var(--salt-spacing-lg); }
.alert-danger { background: var(--salt-color-danger); color: var(--salt-color-on-danger); }
.alert-success { background: var(--salt-color-success); color: var(--salt-color-on-success); }
.alert-warning { background: var(--salt-color-warning); color: var(--salt-color-on-warning); }
.alert-info { background: var(--salt-color-info); color: var(--salt-color-on-info); }
/* Severity badge using palette steps */
.badge-error {
background: var(--salt-palette-danger-50);
border: 1px solid var(--salt-palette-danger-200);
color: var(--salt-palette-danger-700);
border-radius: var(--salt-radius-pill);
padding: var(--salt-spacing-xs) var(--salt-spacing-sm);
font-size: var(--salt-font-size-xs);
font-weight: 600;
}
TypeScript build script
npm install --save-dev tsx
// scripts/build-theme.ts
import { generateTheme, generateCssVariables } from 'salt-theme-gen';
import { writeFileSync, mkdirSync } from 'node:fs';
const theme = generateTheme({ preset: 'ocean' });
const { css } = generateCssVariables(theme, { format: 'both' });
mkdirSync('public', { recursive: true });
writeFileSync('public/theme.css', `/* Generated by salt-theme-gen */\n\n${css}`, 'utf8');
console.log('✓ public/theme.css written');
npx tsx scripts/build-theme.ts
Switching presets at runtime
Generate one CSS file per preset at build time, then swap stylesheets:
// scripts/build-all-themes.mjs
import { generateTheme, generateCssVariables } from 'salt-theme-gen';
import { writeFileSync, mkdirSync } from 'node:fs';
const presets = ['ocean', 'forest', 'sunset', 'midnight', 'peacock'];
mkdirSync('public/themes', { recursive: true });
for (const preset of presets) {
const theme = generateTheme({ preset });
const { css } = generateCssVariables(theme, { format: 'both' });
writeFileSync(`public/themes/${preset}.css`, css, 'utf8');
console.log(`✓ themes/${preset}.css`);
}
// Runtime preset switch
function switchPreset(name) {
const link = document.querySelector('link[data-theme-preset]') ?? (() => {
const el = document.createElement('link');
el.rel = 'stylesheet';
el.dataset.themePreset = '';
document.head.appendChild(el);
return el;
})();
link.href = `/themes/${name}.css`;
}
switchPreset('forest'); // instant switch
Checklist
scripts/build-theme.mjsrunsgenerateCssVariables(theme, { format: 'both' })and writes topublic/theme.css✓<link rel="stylesheet" href="/theme.css">in every HTML<head>✓- Inline
<script>applies saveddata-themebefore first paint ✓ styles.cssusesvar(--salt-color-background)— no hardcoded values ✓- Toggle script sets
data-themeand writes tolocalStorage✓
Browser extension? Use the runtime injection approach — content scripts can call generateTheme() and generateCssVariables() and inject a <style> element. The build script approach works equally well if your extension uses a bundler (Vite, Webpack).
Live demo
Open this integration in StackBlitz — fully working, editable in your browser.
Open in StackBlitz →