The pain this chapter solves

You call generateTheme() and get a JavaScript object. Getting those values into CSS means writing a loop, building strings, handling light and dark selectors, and hoping you didn't miss any token group. Everyone does it differently and it's boilerplate every project has to re-invent.

Chapter 13

CSS Custom Properties

The gap between the JS object and CSS

generateTheme() gives you a perfectly structured JavaScript object. But your CSS and components need CSS custom propertiesvar(--salt-color-primary), not theme.light.colors.primary.

Without a dedicated function, every team writes their own serializer. The problems:

  • Forget to include tonal palettes (88 extra vars)
  • Forget state colors (32 vars)
  • Different token naming conventions across projects — --color-primary vs --salt-primary vs --brand-primary
  • oklch values serialized as hex because nobody wrote the conversion
  • No @supports fallback for older browsers

generateCssVariables() solves all of this in one call.


Basic usage

import { generateTheme, generateCssVariables } from 'salt-theme-gen';

const theme = generateTheme({ preset: 'ocean' });
const { css } = generateCssVariables(theme);

// css is a ready-to-use string:
// :root { --salt-color-primary: #0077dc; ... }
// [data-theme='dark'] { --salt-color-primary: #459af9; ... }

Write it to a file, inject it into a <style> tag, or pass it to your CSS-in-JS library. The choice is yours — generateCssVariables only produces the string.


The —salt prefix

All variables use the --salt namespace:

--salt-color-primary
--salt-palette-primary-500
--salt-surface-card
--salt-state-primary-hover
--salt-spacing-md
--salt-radius-pill
--salt-font-size-md

The prefix is fixed. It is the library’s namespace — it prevents collisions with your own custom properties and makes it obvious where each variable comes from.


Full token naming reference

Token groupPatternExample
Semantic colors (21)--salt-color-{key}--salt-color-primary, --salt-color-on-primary
Tonal palettes (88)--salt-palette-{key}-{step}--salt-palette-primary-500
Surface elevation (4)--salt-surface-{key}--salt-surface-card, --salt-surface-modal
State colors (32)--salt-state-{intent}-{state}--salt-state-primary-hover
Spacing (7)--salt-spacing-{key}--salt-spacing-md: 12px
Border radius (7)--salt-radius-{key}--salt-radius-pill: 9999px
Font sizes (7)--salt-font-size-{key}--salt-font-size-md: 16px
Icon sizes--salt-icon-size-{key}--salt-icon-size-md: 24px
Component sizes--salt-size-{key}--salt-size-md: 40px
Base dimension--salt-dimension-{key}--salt-dimension-md

camelCase keys are converted to kebab-case automatically: onPrimaryon-primary, onBackgroundon-background.


Color formats

format: 'hex' (default)

All color values output as #rrggbb. This is the safe default — works everywhere, no browser feature checks required.

const { css } = generateCssVariables(theme);
// or explicitly:
const { css } = generateCssVariables(theme, { format: 'hex' });
:root {
  --salt-color-primary: #0077dc;
  --salt-color-background: #f5f8ff;
  --salt-spacing-md: 12px;
}

[data-theme='dark'] {
  --salt-color-primary: #459af9;
  --salt-color-background: #0e1724;
}

format: 'oklch'

All color values output as oklch(L% C H). Best for design tools, token pipelines, and codebases that target modern browsers (Chrome 111+, Firefox 113+, Safari 15.4+).

const { css } = generateCssVariables(theme, { format: 'oklch' });
:root {
  --salt-color-primary: oklch(57.07% 0.1776 253.29);
  --salt-color-background: oklch(97.80% 0.0080 253.00);
  --salt-spacing-md: 12px;   /* non-color tokens always px */
}

[data-theme='dark'] {
  --salt-color-primary: oklch(67.90% 0.1629 253.44);
}

OKLCH values preserve the full precision of the generated color — no hex quantization. This means smoother gradients and better interpolation in color-mix().

format: 'both' — progressive enhancement

The best of both worlds: hex everywhere, oklch where supported. Uses @supports (color: oklch(0 0 0)) so older browsers silently fall back to hex with zero extra JavaScript.

const { css } = generateCssVariables(theme, { format: 'both' });
/* Hex fallback — all browsers */
:root {
  --salt-color-primary: #0077dc;
  --salt-color-background: #f5f8ff;
  --salt-spacing-md: 12px;
  /* ...all 150+ tokens */
}

[data-theme='dark'] {
  --salt-color-primary: #459af9;
}

/* oklch override — modern browsers only */
@supports (color: oklch(0 0 0)) {
  :root {
    --salt-color-primary: oklch(57.07% 0.1776 253.29);
    /* only color vars repeated here — spacing/radius stay in the main block */
  }
  [data-theme='dark'] {
    --salt-color-primary: oklch(67.90% 0.1629 253.44);
  }
}

Note: the @supports block only repeats color variables. Spacing, radius, font size, and other numeric tokens are not repeated — they don’t need a color format fallback.


Custom selectors

By default, light tokens are on :root and dark tokens are on [data-theme='dark']. Override either:

// Media query dark mode (no JavaScript needed)
const { css } = generateCssVariables(theme, {
  lightSelector: ':root',
  darkSelector: '@media (prefers-color-scheme: dark)',
});
:root {
  --salt-color-primary: #0077dc;
}

@media (prefers-color-scheme: dark) {
  :root {
    --salt-color-primary: #459af9;
  }
}
// Class-based (Tailwind dark mode strategy)
const { css } = generateCssVariables(theme, {
  lightSelector: ':root',
  darkSelector: '.dark',
});
// Scoped to a component subtree
const { css } = generateCssVariables(theme, {
  lightSelector: '#app',
  darkSelector: '#app[data-theme="dark"]',
});

The light and dark fields

Beyond css, the result includes raw declaration strings without their selectors — useful for custom wiring:

const { css, light, dark } = generateCssVariables(theme);

// light: a multi-line string of declarations only (no selector wrapper)
// "--salt-color-primary: #0077dc;\n--salt-color-background: #f5f8ff;\n..."

// Inject into a shadow DOM component
shadowRoot.innerHTML = `<style>:host { ${light} }</style>`;

// Or build a React context that injects mode-aware styles
const style = `
  :root { ${light} }
  :root[data-theme='dark'] { ${dark} }
`;

In format: 'both' mode, light and dark contain the hex declarations. The css field has the full combined output including the @supports block.


Build script — write to a file

The most common pattern: generate once at build time, serve a static CSS file.

// scripts/build-theme.mjs
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 — do not edit */\n\n${css}`, 'utf8');
console.log('✓ theme.css written');
node scripts/build-theme.mjs
<!-- In your HTML -->
<link rel="stylesheet" href="/theme.css" />

Zero runtime JavaScript. The CSS file ships with your static assets.


Runtime inject — no build step

For SPAs and browser extensions, inject at runtime:

import { generateTheme, generateCssVariables } from 'salt-theme-gen';

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); // prepend so it's the lowest-priority stylesheet

Prepending (not appending) means your own overrides always win.


Dark mode toggle

With [data-theme='dark'] as the selector (the default), toggling dark mode is a single attribute:

// Toggle
const root = document.documentElement;
root.dataset.theme = root.dataset.theme === 'dark' ? 'light' : 'dark';

// Set from system preference on first load
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.dataset.theme = prefersDark ? 'dark' : 'light';

// Listen for OS-level changes
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', e => {
  document.documentElement.dataset.theme = e.matches ? 'dark' : 'light';
});

All 150+ CSS variables switch simultaneously with zero rerender.


Using the variables in CSS

Once the variables are injected, use them exactly like any CSS custom property:

.button-primary {
  background-color: var(--salt-color-primary);
  color: var(--salt-color-on-primary);
  padding: var(--salt-spacing-sm) var(--salt-spacing-md);
  border-radius: var(--salt-radius-md);
  font-size: var(--salt-font-size-md);
}

.button-primary:hover    { background-color: var(--salt-state-primary-hover); }
.button-primary:active   { background-color: var(--salt-state-primary-pressed); }
.button-primary:focus-visible {
  outline: 2px solid var(--salt-state-primary-focused);
  outline-offset: 2px;
}
.button-primary:disabled {
  background-color: var(--salt-state-primary-disabled);
  cursor: not-allowed;
}

.card {
  background-color: var(--salt-surface-card);
  border: 1px solid var(--salt-color-border);
  border-radius: var(--salt-radius-lg);
  padding: var(--salt-spacing-lg);
}

/* Palette step for a badge */
.badge-danger {
  background-color: var(--salt-palette-danger-50);
  border-color: var(--salt-palette-danger-200);
  color: var(--salt-palette-danger-700);
}

Light and dark mode just work — the variable values swap when [data-theme='dark'] is set.


TypeScript types

import type { CssFormat, CssVariablesOptions, CssVariablesResult } from 'salt-theme-gen';

// CssFormat: 'hex' | 'oklch' | 'both'

// CssVariablesOptions:
// {
//   format?: CssFormat;        // default 'hex'
//   lightSelector?: string;    // default ':root'
//   darkSelector?: string;     // default "[data-theme='dark']"
// }

// CssVariablesResult:
// {
//   css: string;    // full stylesheet
//   light: string;  // light declarations only (no selector)
//   dark: string;   // dark declarations only (no selector)
// }

What’s next

generateCssVariables handles the CSS side. If you use Tailwind CSS, the next integration guide shows generateTailwindConfig() — which maps all tokens to Tailwind utility classes instead of raw CSS variables. If you need to export tokens to design tools like Token Studio or Style Dictionary, Chapter 14 covers generateDtcgTokens().