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() and generateCssVariables()
  • 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 scriptRuntime inject
HowNode script → writes theme.css<script> → injects <style>
Runtime JSZero~10 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, 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.mjs runs generateCssVariables(theme, { format: 'both' }) and writes to 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(--salt-color-background) — no hardcoded values ✓
  • Toggle script sets data-theme and writes to localStorage

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 →