The pain this chapter solves
Semantic tokens cover buttons and text, but charts need six shades of blue. Severity badges need a background tint and a text color that aren't in your semantic set. You end up eyeballing hex values that don't belong to any system.
Chapter 12
Tonal Palettes
When semantic tokens aren’t enough
The 23 semantic tokens cover 90% of UI work. But some patterns need a graduated scale:
- A bar chart where each bar is a different shade of the primary blue
- A heatmap where cells range from near-white to deep red
- A severity badge system: light red background, medium red border, dark red text — all from the same hue
- A pricing table where tier 1, 2, and 3 have progressively deeper tones of the brand color
For these, you need a tonal palette — a range of shades along a single hue, from near-white to near-black, with even perceptual steps.
The palette in the theme output
Every generated theme includes 8 tonal palettes, one for each intent color. They are already there — you don’t need to call anything extra:
const theme = generateTheme({ preset: 'ocean' });
// 8 palettes, each with 11 steps
theme.light.palettes.primary // primary blue at steps 50–950
theme.light.palettes.secondary
theme.light.palettes.tertiary
theme.light.palettes.quaternary
theme.light.palettes.danger
theme.light.palettes.success
theme.light.palettes.warning
theme.light.palettes.info
Each palette has 11 steps:
theme.light.palettes.primary[50] // near-white tint
theme.light.palettes.primary[100]
theme.light.palettes.primary[200]
theme.light.palettes.primary[300]
theme.light.palettes.primary[400]
theme.light.palettes.primary[500] // mid-range — close to the semantic primary
theme.light.palettes.primary[600]
theme.light.palettes.primary[700]
theme.light.palettes.primary[800]
theme.light.palettes.primary[900]
theme.light.palettes.primary[950] // near-black deep tone
Step 500 is not guaranteed to match colors.primary exactly — the semantic primary is tuned for contrast and harmony while the palette is a pure tonal scale. Think of them as related but distinct tools.
How the steps are generated
Steps follow the standard Tailwind-style naming (50, 100–900, 950), but the values are computed in OKLCH space, not HSL. This matters for perceptual consistency.
In HSL, a 10-step scale often looks uneven — yellows appear brighter than blues at the “same” lightness. In OKLCH, equal lightness steps look equally different to the human eye.
The scale covers L = 0.97 (step 50) down to L = 0.10 (step 950). Chroma follows a bell curve — peaking near step 500 for maximum saturation, falling toward white at the light end and black at the dark end (because fully saturated dark and light tones are rarely in-gamut anyway).
Hue stays fixed across all 11 steps — this is what makes it a “tonal” palette. The hue is the hue of the source semantic color.
Generating a single palette
If you need a palette for an arbitrary color — not one of the 8 intents — use generateTonalPalette directly:
import { generateTonalPalette } from 'salt-theme-gen';
const brandPalette = generateTonalPalette('#0f4c81');
// Returns TonalPalette — an object with keys 50, 100, 200...950
brandPalette[50] // '#f0f5fb' — lightest tint
brandPalette[500] // '#3a7ec8' — mid-range
brandPalette[900] // '#0d2a4a' — deepest tone
This is useful when you have a custom brand color that sits outside your semantic set — a partner brand color, a chart series color, or a decorator hue.
Generating all 8 palettes at once
import { generateTonalPalettes } from 'salt-theme-gen';
const theme = generateTheme({ preset: 'forest' });
const palettes = generateTonalPalettes(theme);
// Same result as theme.light.palettes / theme.dark.palettes
// Useful when you need both modes side by side
const { light, dark } = palettes;
generateTonalPalettes returns { light, dark } where each side is a TonalPalettes object (all 8 palettes).
Usage patterns
Severity badges
A badge system where background, border, and text all come from the danger scale:
const danger = theme.light.palettes.danger;
const errorBadge = {
backgroundColor: danger[50], // very light red background
borderColor: danger[200], // subtle red border
color: danger[700], // dark red text — readable on light bg
};
No eyeballing. No arbitrary hex values. All three tones come from the same hue and the same system.
Chart series
Eight bars, each a step of the primary palette:
const steps = [100, 200, 300, 400, 500, 600, 700, 800];
const chartColors = steps.map(s => theme.light.palettes.primary[s]);
// Each bar uses a progressively darker shade of the brand primary
Heatmap cells
A heatmap where intensity maps to palette depth:
function heatColor(intensity: number): string {
// intensity 0–1 → palette step 100–900
const step = Math.round(intensity * 8) * 100 + 100;
return theme.light.palettes.danger[step as keyof TonalPalette];
}
Tinted section backgrounds
A marketing page where each section has a slightly different background tint:
.section-intro { background: var(--salt-palette-primary-50); }
.section-features { background: var(--salt-palette-primary-100); }
.section-pricing { background: var(--salt-palette-primary-200); }
Palette tokens in CSS
When you call generateCssVariables(theme) (covered in the next chapter), all 88 palette steps are automatically included as CSS custom properties:
:root {
--salt-palette-primary-50: #f0f6ff;
--salt-palette-primary-100: #deeaff;
--salt-palette-primary-200: #bad3ff;
--salt-palette-primary-300: #8bb6ff;
--salt-palette-primary-400: #5b95f5;
--salt-palette-primary-500: #3a7ec8;
--salt-palette-primary-600: #2d65a8;
--salt-palette-primary-700: #214d88;
--salt-palette-primary-800: #163769;
--salt-palette-primary-900: #0c2350;
--salt-palette-primary-950: #060f28;
/* ...same pattern for secondary, tertiary, quaternary, danger, success, warning, info */
}
8 palettes × 11 steps = 88 CSS variables, automatically kept in sync with your theme.
TypeScript types
import type { TonalPalette, TonalPalettes, TonalPaletteKey, TonalStep } from 'salt-theme-gen';
// TonalStep: 50 | 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 | 950
// TonalPaletteKey: 'primary' | 'secondary' | 'tertiary' | 'quaternary' | 'danger' | 'success' | 'warning' | 'info'
// TonalPalette: Record<TonalStep, string> (hex values)
// TonalPalettes: Record<TonalPaletteKey, TonalPalette>
function getStep(palette: TonalPalette, step: TonalStep): string {
return palette[step];
}
Light vs dark palettes
The palettes in theme.light.palettes and theme.dark.palettes are the same 11-step scales. Because they are pure tonal scales (not semantically adjusted for dark mode), the light and dark values are very similar — the hue and chroma relationship stays the same.
If you need your palette to flip for dark mode (light steps become dark, dark steps become light), you can invert the mapping:
function getAdaptiveTone(
theme: GeneratedTheme,
palette: TonalPaletteKey,
step: TonalStep,
mode: 'light' | 'dark'
): string {
const p = theme[mode].palettes[palette];
if (mode === 'dark') {
// Invert: step 100 → 900, 500 → 500, 900 → 100
const invertedStep = (1000 - step) as TonalStep;
return p[invertedStep] ?? p[step];
}
return p[step];
}
This gives you a badge background that is a light tint in light mode and a dark tint in dark mode — which is the visually correct behavior for most UI patterns.
When to use palettes vs semantic tokens
| Use case | Use |
|---|---|
| Button fill | colors.primary |
| Text on that button | colors.onPrimary |
| Button hover | states.primary.hover |
| Badge background (subtle tint) | palettes.danger[50] or [100] |
| Badge text (readable on tint) | palettes.danger[700] or [800] |
| Chart bar series | palettes.primary[100] through [800] |
| Heatmap intensity | palettes.danger[step] |
| Section accent background | palettes.primary[50] or [100] |
| Decorative gradient | palettes.secondary[200] to [600] |
The rule of thumb: semantic tokens for interactive UI elements, tonal palettes for data visualization and decorative surfaces.