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() - A plain CSS file with all tokens as custom properties — no JavaScript required at runtime
- A dark mode toggle in ~15 lines of JavaScript
- Optional: a TypeScript version with full type safety
Time required: 10 minutes.
Two approaches
| Build script | Runtime inject | |
|---|---|---|
| How | Node script → writes theme.css | <script> → injects <style> |
| Runtime JS | Zero | ~20 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 } from 'salt-theme-gen';
import { writeFileSync, mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
const theme = generateTheme({
preset: 'ocean',
spacing: 'default',
radius: 'default',
fontSize: 'default',
});
function kebab(str) {
return str.replace(/([A-Z])/g, '-$1').toLowerCase();
}
function modeToVars(mode) {
const lines = [];
for (const [k, v] of Object.entries(mode.colors))
lines.push(` --color-${kebab(k)}: ${v};`);
for (const [k, v] of Object.entries(mode.surfaceElevation))
lines.push(` --surface-${k}: ${v};`);
for (const [k, v] of Object.entries(mode.spacing))
lines.push(` --space-${k}: ${v}px;`);
for (const [k, v] of Object.entries(mode.radius))
lines.push(` --radius-${k}: ${v}px;`);
for (const [k, v] of Object.entries(mode.fontSizes))
lines.push(` --text-${k}: ${v}px;`);
for (const [intent, states] of Object.entries(mode.states))
for (const [state, val] of Object.entries(states))
lines.push(` --state-${intent}-${state}: ${val};`);
return lines.join('\n');
}
const css = `/* Generated by salt-theme-gen — do not edit manually */
:root {
${modeToVars(theme.light)}
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
${modeToVars(theme.dark)}
}
}
:root[data-theme="dark"] {
${modeToVars(theme.dark)}
}
:root[data-theme="light"] {
${modeToVars(theme.light)}
}
`;
const outPath = 'public/theme.css';
mkdirSync(dirname(outPath), { recursive: true });
writeFileSync(outPath, css, 'utf8');
console.log(`✓ theme.css written to ${outPath}`);
Run it
node scripts/build-theme.mjs
Add to your package.json scripts 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 -->
<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.
Approach 2 — Runtime injection
No build step required. Call generateTheme() in a <script type="module"> and inject the variables:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>My Site</title>
<!-- Apply saved preference before theme script runs -->
<script>
(function () {
var t = localStorage.getItem('theme');
if (t === 'dark' || t === 'light')
document.documentElement.setAttribute('data-theme', t);
}());
</script>
<script type="module">
import { generateTheme } from 'https://esm.sh/salt-theme-gen@1.2.2';
const theme = generateTheme({ preset: 'ocean' });
function kebab(str) {
return str.replace(/([A-Z])/g, '-$1').toLowerCase();
}
function modeToVars(mode) {
const parts = [];
for (const [k, v] of Object.entries(mode.colors))
parts.push(`--color-${kebab(k)}: ${v}`);
for (const [k, v] of Object.entries(mode.spacing))
parts.push(`--space-${k}: ${v}px`);
for (const [k, v] of Object.entries(mode.radius))
parts.push(`--radius-${k}: ${v}px`);
for (const [k, v] of Object.entries(mode.fontSizes))
parts.push(`--text-${k}: ${v}px`);
for (const [intent, states] of Object.entries(mode.states))
for (const [state, val] of Object.entries(states))
parts.push(`--state-${intent}-${state}: ${val}`);
return parts.join(';');
}
const style = document.createElement('style');
style.textContent = `
:root { ${modeToVars(theme.light)} }
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) { ${modeToVars(theme.dark)} }
}
:root[data-theme="dark"] { ${modeToVars(theme.dark)} }
:root[data-theme="light"] { ${modeToVars(theme.light)} }
`;
document.head.appendChild(style);
</script>
</head>
</html>
esm.sh converts npm packages to ES modules — no bundler needed.
Dark mode toggle
Plain JavaScript, no framework:
<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.setAttribute('aria-label', `Switch to ${mode === 'dark' ? 'light' : 'dark'} mode`);
}
// Set initial button state
btn.textContent = getResolvedMode() === 'dark' ? '☀' : '◐';
btn.addEventListener('click', () => {
setMode(getResolvedMode() === 'dark' ? 'light' : 'dark');
});
// Keep in sync with OS preference changes
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
const saved = localStorage.getItem('theme');
if (!saved || saved === 'system') setMode(e.matches ? 'dark' : 'light');
});
</script>
Using tokens in plain CSS
Reference variables in any stylesheet — no imports, no preprocessor:
/* styles.css */
*, *::before, *::after { box-sizing: border-box; }
body {
margin: 0;
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;
transition: background-color 0.2s, color 0.2s;
}
/* Navigation */
.nav {
background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
padding: var(--space-md) var(--space-xl);
display: flex;
align-items: center;
gap: var(--space-lg);
}
/* Buttons */
.btn {
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;
}
.btn-primary {
background: var(--color-primary);
color: var(--color-on-primary);
}
.btn-primary:hover { background: var(--state-primary-hover); }
.btn-primary:active { background: var(--state-primary-pressed); }
.btn-primary:focus-visible {
outline: 2px solid var(--state-primary-focused);
outline-offset: 2px;
}
.btn-primary:disabled {
background: var(--state-primary-disabled);
cursor: not-allowed;
}
/* Cards */
.card {
background: var(--surface-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-xl);
}
.card-title {
font-size: var(--text-lg);
font-weight: 700;
color: var(--color-text);
margin: 0 0 var(--space-sm);
}
.card-body {
font-size: var(--text-md);
color: var(--color-muted);
line-height: 1.7;
margin: 0;
}
/* Alerts */
.alert { border-radius: var(--radius-md); padding: var(--space-md) var(--space-lg); }
.alert-danger { background: var(--color-danger); color: var(--color-on-danger); }
.alert-success { background: var(--color-success); color: var(--color-on-success); }
.alert-warning { background: var(--color-warning); color: var(--color-on-warning); }
.alert-info { background: var(--color-info); color: var(--color-on-info); }
TypeScript build script
If you prefer TypeScript for the build script, install tsx:
npm install --save-dev tsx
Rename to scripts/build-theme.ts and add types:
import { generateTheme } from 'salt-theme-gen';
import type { GeneratedThemeMode } from 'salt-theme-gen';
import { writeFileSync, mkdirSync } from 'node:fs';
const theme = generateTheme({ preset: 'ocean' });
function kebab(str: string): string {
return str.replace(/([A-Z])/g, '-$1').toLowerCase();
}
function modeToVars(mode: GeneratedThemeMode): string {
const lines: string[] = [];
for (const [k, v] of Object.entries(mode.colors))
lines.push(` --color-${kebab(k)}: ${v};`);
for (const [k, v] of Object.entries(mode.spacing))
lines.push(` --space-${k}: ${v}px;`);
for (const [k, v] of Object.entries(mode.radius))
lines.push(` --radius-${k}: ${v}px;`);
for (const [k, v] of Object.entries(mode.fontSizes))
lines.push(` --text-${k}: ${v}px;`);
for (const [intent, states] of Object.entries(mode.states))
for (const [state, val] of Object.entries(states as Record<string, string>))
lines.push(` --state-${intent}-${state}: ${val};`);
return lines.join('\n');
}
const css = `/* Generated by salt-theme-gen */
:root {\n${modeToVars(theme.light)}\n}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {\n${modeToVars(theme.dark)}\n }
}
:root[data-theme="dark"] {\n${modeToVars(theme.dark)}\n}
:root[data-theme="light"] {\n${modeToVars(theme.light)}\n}
`;
mkdirSync('public', { recursive: true });
writeFileSync('public/theme.css', css);
console.log('✓ public/theme.css written');
Run with:
npx tsx scripts/build-theme.ts
Switching presets
To support user-selectable themes at runtime, generate multiple CSS files and swap the <link>:
# Generate all presets during build
node -e "
const themes = ['ocean','forest','sunset','aurora','midnight'];
const { generateTheme } = await import('salt-theme-gen');
// ... write theme-{name}.css for each
"
// Runtime preset switch
function switchPreset(name) {
const link = document.querySelector('link[data-theme-preset]');
link.href = `/themes/theme-${name}.css`;
}
Checklist
scripts/build-theme.mjsgeneratespublic/theme.css✓<link rel="stylesheet" href="/theme.css">in every HTML<head>✓- Inline
<script>applies saveddata-themebefore first paint ✓ styles.cssusesvar(--color-background)on body ✓- Toggle script sets
data-themeand writes tolocalStorage✓ - No hardcoded color values in CSS ✓
Browser extension? Use the runtime injection approach — content scripts can call generateTheme() and inject a <style> element into the page. The build script approach requires a bundler step, which browser extension tooling (Vite, Webpack) handles automatically.
Live demo
Open this integration in StackBlitz — fully working, editable in your browser.
Open in StackBlitz →