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 caseUse
Button fillcolors.primary
Text on that buttoncolors.onPrimary
Button hoverstates.primary.hover
Badge background (subtle tint)palettes.danger[50] or [100]
Badge text (readable on tint)palettes.danger[700] or [800]
Chart bar seriespalettes.primary[100] through [800]
Heatmap intensitypalettes.danger[step]
Section accent backgroundpalettes.primary[50] or [100]
Decorative gradientpalettes.secondary[200] to [600]

The rule of thumb: semantic tokens for interactive UI elements, tonal palettes for data visualization and decorative surfaces.