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 properties — var(--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-primaryvs--salt-primaryvs--brand-primary - oklch values serialized as hex because nobody wrote the conversion
- No
@supportsfallback 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 group | Pattern | Example |
|---|---|---|
| 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: onPrimary → on-primary, onBackground → on-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,lightanddarkcontain the hex declarations. Thecssfield has the full combined output including the@supportsblock.
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().