Table of Contents
A color palette is not just a list of nice-looking colors. In a design system, a palette is a structured set of tokens — from primitive base values through semantic aliases — that makes it possible for an entire team to build consistent, accessible, and themeable interfaces without thinking about specific color codes every time they write a component.
This guide walks you through building a palette that is production-ready: how many colors to define, how to generate a perceptually even scale for each hue, how to layer semantic tokens on top of primitives, how to handle dark mode, and how to validate accessibility before shipping. By the end, you will have a complete palette architecture you can implement in CSS custom properties, Tailwind config, or any design token format.
Step 1 — Establish Your Palette Needs
Before generating any colors, define what the palette must communicate and support. Answer these questions:
What is the brand's primary hue? Most design systems are anchored to a single dominant brand color (e.g., Stripe blue, GitHub dark, Vercel black). This becomes the seed for your most prominent color scale.
How many accent colors do you need? Most product UIs need:
- 1 primary (brand, main actions, links)
- 1 secondary or accent (supporting actions, hover states, selected states)
- 1 neutral scale (text, backgrounds, borders, dividers)
- 4 semantic colors (success/green, warning/yellow or orange, error/red, info/blue)
Does the product need a light mode, dark mode, or both? This affects how many steps your scales need (dark mode requires more lightness variety) and how semantic tokens are mapped.
What level of accessibility is required? AA or AAA? This constrains which palette values can be used as text colors.
Step 2 — Generate Base Scales
A color scale is a range of tints and shades for a single hue, typically 10 numbered steps. The Tailwind convention (50, 100, 200, … 900, 950) is a good model — the numbers do not have intrinsic meaning but give room to add steps without renaming.
Generating in OKLCH (recommended for new systems):
OKLCH's perceptual uniformity means equal steps in L give visually equal steps in brightness:
--blue-50: oklch(97% 0.04 250); --blue-100: oklch(93% 0.07 250); --blue-200: oklch(87% 0.10 250); --blue-300: oklch(79% 0.14 250); --blue-400: oklch(70% 0.17 250); --blue-500: oklch(60% 0.20 250); /* primary brand value */ --blue-600: oklch(50% 0.20 250); --blue-700: oklch(40% 0.19 250); --blue-800: oklch(30% 0.16 250); --blue-900: oklch(20% 0.12 250); --blue-950: oklch(12% 0.08 250);
Generating in HSL (for broader tooling compatibility):
--blue-50: hsl(220, 90%, 97%); --blue-100: hsl(220, 85%, 93%); --blue-200: hsl(220, 80%, 85%); --blue-300: hsl(220, 75%, 74%); --blue-400: hsl(220, 72%, 62%); --blue-500: hsl(220, 70%, 50%); /* primary brand value */ --blue-600: hsl(220, 72%, 40%); --blue-700: hsl(220, 75%, 32%); --blue-800: hsl(220, 80%, 24%); --blue-900: hsl(220, 85%, 15%);
Note that in HSL, equal lightness steps do not produce perceptually equal steps — the 500→400 jump may look smaller than the 900→800 jump. In OKLCH this is avoided.
Tip: Use LevnTools' shades generator to produce a 10-step scale from any seed color. The tool outputs both HSL and hex for each step.
Generate a color scale— 10-step tints and shades from any seed colorStep 3 — Define the Neutral Scale
The neutral scale is the workhorse of any UI — text, backgrounds, borders, and dividers all draw from it. A purely grey neutral (no hue) can feel cold; a slightly warm or cool neutral reads as more intentional.
Warm neutral (slightly yellow/orange undertone):
--neutral-50: hsl(40, 20%, 98%); --neutral-100: hsl(40, 15%, 95%); --neutral-200: hsl(40, 12%, 90%); --neutral-300: hsl(40, 10%, 80%); --neutral-400: hsl(40, 8%, 65%); --neutral-500: hsl(40, 6%, 50%); --neutral-600: hsl(40, 5%, 38%); --neutral-700: hsl(40, 5%, 28%); --neutral-800: hsl(40, 5%, 18%); --neutral-900: hsl(40, 5%, 10%);
Cool neutral (slightly blue undertone — common in tech products):
Use hue angle ~220 with low saturation (3–8%).
The neutral hue should typically match or complement the brand's primary hue to create visual harmony.
Step 4 — Define Semantic Tokens
Semantic tokens are aliases that describe *intent*, not specific color values. They are the layer that makes dark mode and theming possible without changing component code.
Structure:
Primitive token: --blue-600 (a specific color value) Semantic token: --color-primary (references --blue-600 in light mode)
Core semantic categories:
:root {
/* Brand */
--color-brand: var(--blue-600);
--color-brand-hover: var(--blue-700);
--color-brand-subtle: var(--blue-100);
/* Text */
--color-text-primary: var(--neutral-900);
--color-text-secondary: var(--neutral-600);
--color-text-tertiary: var(--neutral-400);
--color-text-disabled: var(--neutral-300);
--color-text-inverse: var(--neutral-50);
--color-text-link: var(--blue-600);
/* Background */
--color-bg-page: var(--neutral-50);
--color-bg-surface: #ffffff;
--color-bg-raised: var(--neutral-100);
--color-bg-overlay: var(--neutral-200);
/* Border */
--color-border: var(--neutral-200);
--color-border-strong: var(--neutral-400);
--color-border-focus: var(--blue-500);
/* Semantic states */
--color-success: var(--green-600);
--color-success-subtle: var(--green-100);
--color-warning: var(--amber-500);
--color-warning-subtle: var(--amber-100);
--color-error: var(--red-600);
--color-error-subtle: var(--red-100);
--color-info: var(--blue-600);
--color-info-subtle: var(--blue-100);
}Dark mode — reassign semantic tokens, not primitives:
@media (prefers-color-scheme: dark) {
:root {
--color-text-primary: var(--neutral-50);
--color-text-secondary: var(--neutral-400);
--color-bg-page: var(--neutral-950);
--color-bg-surface: var(--neutral-900);
--color-border: var(--neutral-700);
--color-brand: var(--blue-400); /* lighter in dark mode */
}
}Only semantic tokens change between themes. Primitive tokens (the scale steps) are static.
Step 5 — Validate Accessibility
Before finalizing the palette, run these checks:
Text on background pairs:
Every semantic text/background combination must pass WCAG 2.1 AA:
--color-text-primaryon--color-bg-page: must be ≥ 4.5:1--color-text-secondaryon--color-bg-surface: must be ≥ 4.5:1--color-brand(when used as text color) on white: must be ≥ 4.5:1- All semantic state text on their subtle backgrounds: check each
Common failures and fixes:
- Brand blue at 50% lightness on white often falls just below 4.5:1 → use step-600 or step-700 for text
- Amber/yellow semantic warning on white rarely passes → use text color from neutral-800 and amber-100 background
- In dark mode, light text on dark brand backgrounds can fail if the brand color is too dark → lighten brand by 1–2 scale steps in dark mode
Check in LevnTools' contrast checker for each pair before writing component code.
Test your palette's contrast— Paste any two hex/HSL values and get instant WCAG resultStep 6 — Test for Color Blindness
Your semantic state colors (green for success, red for error) must be distinguishable by users with color blindness. Approximately 8% of men have some form of red-green color blindness (deuteranopia or protanopia).
Rules:
- Never use color alone to convey state — always pair with an icon, label, or pattern
- The success green and error red should differ in luminance, not just hue, so they distinguish in greyscale too
- Check your full palette through the blindness simulator to verify no critical pairs are indistinguishable
Typical fix: Ensure your error red is darker (lower L in OKLCH) than your success green, so they differ in both hue and brightness. A deuteranopia user cannot distinguish red from green by hue, but can distinguish light from dark.
Simulate color blindness— Preview all 8 vision types including deuteranopia and protanopiaFrequently Asked Questions
How many colors should a design system palette have?
Should I use OKLCH or HSL for my design system?
How do I handle brand colors that don't have accessible contrast?
What's the best way to create a dark mode palette?
How many steps should a color scale have?
Summary
A well-structured color palette is one of the highest-leverage investments in a design system. Get the primitive scales right and define semantic tokens correctly, and every component inherits accessibility and dark mode support automatically — without any per-component color decisions.
Start with your brand anchor color, generate a 10-step scale using LevnTools' shades generator, validate contrast ratios for all text/background pairs, and define semantic tokens that translate design intent into CSS custom properties. From there, dark mode is just a reassignment of which scale step each semantic token points to.