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.css file from generateTheme()
  • 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 scriptRuntime inject
HowNode script → writes theme.css<script> → injects <style>
Runtime JSZero~20 lines
Dark mode FOUCNone (with inline script)None (if script is synchronous)
Best forStatic sites, CI pipelines, any serverSPAs, browser extensions

Both are covered below.


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.mjs generates public/theme.css
  • <link rel="stylesheet" href="/theme.css"> in every HTML <head>
  • Inline <script> applies saved data-theme before first paint ✓
  • styles.css uses var(--color-background) on body ✓
  • Toggle script sets data-theme and writes to localStorage
  • 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 →