Color System & Palette Scales
Building 50-500-900 scales, semantic roles, and contrast-safe pairings from a single seed.
Design TokensIntermediate
Why
Ad-hoc hex values create contrast failures and make dark mode impossible. A generated scale from one seed gives you 10 shades that are perceptually uniform, contrast-tested, and invertible for dark mode.
Tokens Studio (formerly Figma Tokens) has 500k+ installs, and the W3C Design Tokens Format Module is nearing candidate recommendation. Tokens enable dark mode, multi-brand theming, and platform parity from a single source.
When
Build a color system before the first screen is designed. If you already have 20+ hex values in Figma, audit and consolidate them into scales immediately.
How
1. Pick seeds — one hue per role (primary, neutral, success, warning, danger)
2. Generate scale — use Radix Colors or Tailwind's palette generator for 50→950 steps
3. Assign semantic roles — action.primary = blue.600, text.muted = gray.500, surface = gray.50
4. Verify contrast — every text/bg pairing must hit 4.5:1 (normal) or 3:1 (large)
5. Dark mode — invert: surface = gray.900, text = gray.100, action.primary = blue.500
6. Tokenize — emit as --color-{role}-{step}, never raw hex in components
7. Document — show the scale, the semantic map, and the contrast matrix
Example
// color-tokens.ts — generated scale + semantic roles
export const color = {
blue: { 50: "#eff6ff", 500: "#3b82f6", 600: "#2563eb", 900: "#1e3a8a" },
gray: { 50: "#f9fafb", 100: "#f3f4f6", 500: "#6b7280", 900: "#111827" },
red: { 500: "#ef4444", 600: "#dc2626" },
} as const;
export const semantic = {
light: { action: color.blue[600], text: color.gray[900], surface: color.gray[50] },
dark: { action: color.blue[500], text: color.gray[100], surface: color.gray[900] },
} as const;
// contrast check — text on surface
// gray.900 (#111827) on gray.50 (#f9fafb) = 15.3:1 ✓ (needs 4.5:1)
// blue.600 (#2563eb) on white = 4.6:1 ✓ (borderline — use blue.700 for body text)
Common Problems
- ⚠Hand-picked shades aren't perceptually uniform — step 500 looks lighter than 400
- ⚠Semantic roles skipped — components use blue.600 directly, dark mode breaks
- ⚠No contrast matrix — 'it looks fine to me' ships a 3.2:1 pairing that fails WCAG
Best Practices
- ✓Generate scales from a seed — Radix Colors or tailwindcss-colors, never hand-pick 10 shades
- ✓Always go through semantic tokens — components reference var(--color-action-primary) only
- ✓Automate contrast checks in CI — stylelint + a custom rule that fails < 4.5:1 pairings