Skip to main content
October 1, 2026 · colors · 10 min read

Building a Design System Palette from Scratch

Picking a base hue, deriving shade ramps in OKLCH, and tuning for contrast — the actual workflow, not the Figma plugin version.

Last updated October 1, 2026 · 10 min read

A palette is not nine hex codes in a Figma frame. It is a set of roles your UI can lean on without the designer being in the room. "I need a success toast background" has to resolve to one answer, every time, in both themes, on all three surface levels. That's the thing worth building.

The strong opinion here: generate shade ramps in OKLCH, not HSL. HSL ramps look even in Figma and feel uneven in production because HSL lightness is not perceptual lightness. OKLCH's L axis is roughly perceptually uniform; a ramp from L=0.1 to L=0.9 in equal steps actually reads as equal steps.

Here is the full workflow, from one base color to a shipping token set.

Step 1 — pick fewer base hues than you think

Count the hues you need. The honest list for most products:

  • Primary brand — one hue.
  • Neutral — one slightly-tinted grey. Pure #808080 reads cheap; tint it 2–3° toward the brand.
  • Danger — one red.
  • Warning — one amber. (Not yellow. Yellow is almost impossible to read as text.)
  • Success — one green.
  • Info — one blue. Only if your primary isn't already blue.

That's six, maximum. You will be tempted to add "secondary brand" and "accent brand." Resist until you have a specific UI job for them. "Accent brand" with no defined usage becomes the color designers reach for when they want to look clever, and the token file balloons.

If you don't have a starting hue, the Brand Color Palette Generator returns coordinated six-hue sets with the roles pre-labeled, which is faster than picking one color and rebuilding the other five by eye.

Step 2 — convert to OKLCH immediately

Say your brand is #2563EB (a Tailwind blue). In OKLCH that's roughly oklch(0.546 0.216 262.9) — lightness 0.546, chroma 0.216, hue 262.9°.

Now you have levers:

  • Hue (262.9°) — stays locked. This is "brand."
  • Chroma (0.216) — controls saturation. Shade ramps lower chroma at the extremes.
  • Lightness (0.546) — the thing you'll vary to produce the 50–950 scale.

HSL would have given you hsl(221, 83%, 53%). A 10% HSL lightness bump produces something that looks darker than another 10% HSL lightness bump at a different hue. OKLCH does not have that problem. Which is why most new design systems (Radix, Open Props, Tailwind 4) are shipping OKLCH internally.

For the hue-to-value math without a converter, the HSL Color Palette Generator gives you an HSL baseline to compare against so you can see the perceptual gap OKLCH is fixing.

Step 3 — build the shade ramp

The convention worth copying: 11 stops named 50, 100, 200 ... 950. Not 9 (too coarse), not 15 (nobody uses the middle ones).

Target lightness values that read as even steps:

| Stop | Target OKLCH L | Chroma rule | |---|---|---| | 50 | 0.97 | base_chroma × 0.15 | | 100 | 0.94 | × 0.25 | | 200 | 0.87 | × 0.5 | | 300 | 0.78 | × 0.75 | | 400 | 0.68 | × 0.9 | | 500 | 0.58 | × 1.0 (base) | | 600 | 0.49 | × 1.0 | | 700 | 0.41 | × 0.9 | | 800 | 0.33 | × 0.75 | | 900 | 0.25 | × 0.55 | | 950 | 0.18 | × 0.35 |

The chroma rule is what makes the ramp look clean. Pure saturation at L=0.9 reads garish; pure saturation at L=0.2 reads muddy. Taper both ends.

For a visual check against your target ramp, generate a reference with the Tints and Shades Generator — if your OKLCH ramp has clearly smoother steps than the tint/shade version, the math is doing its job.

Step 4 — tune for contrast at the roles that matter

The ramp is the raw material. Now assign roles.

Protagonist: Dag, building a dashboard called Switchback. He maps his blue ramp to:

| Role | Light mode | Dark mode | WCAG against surface | |---|---|---|---| | --brand/text | blue-700 | blue-300 | 7.1 / 7.4 | | --brand/surface | blue-600 | blue-500 | — (used behind text) | | --brand/surface-text | white | white | 5.2 / 4.6 | | --brand/muted-bg | blue-50 | blue-950 | — | | --brand/muted-text | blue-900 | blue-100 | 15.1 / 14.3 | | --brand/border | blue-200 | blue-800 | 1.5 / 1.6 (decorative) |

The two interesting lines are --brand/text and --brand/surface-text. In light mode, blue-500 as link text on white fails body AA (4.57:1 is the Tailwind standard, 500 at your particular hue may be lower). Use blue-700. In dark mode, blue-500 as link text on blue-950 fails too — you want blue-300.

Design systems that use the same stop for both themes are shipping broken contrast. The pattern "brand/text is always 500" is a smell.

Run every pair through the WCAG Contrast Pair Generator to confirm the target threshold is hit before shipping tokens.

Step 5 — neutrals matter more than the brand

Neutrals are 70% of the pixels. Designers obsess over the brand ramp and ship a flat grey scale that makes the whole product feel cheap.

Three improvements over plain grey:

Tint toward the brand. If brand hue is 262°, neutrals use chroma 0.01 at hue 262°. Nobody will notice individually; everything will feel coordinated.

Use five neutral tones, not three. Base, raised, muted, strong-text, body-text. Three is not enough to layer two cards.

The darkest body text should be around L=0.2, not L=0. Pure black on white is harsher than most designers realise. #000 is a 21:1 contrast, visually fatiguing. L=0.15 is 17.5:1 — passes AAA, reads calmer.

If you want a reference set to compare, the Random UI Color System Generator generates full surface + text + accent sets you can diff against your own to spot the places you shortchanged the neutrals.

Step 6 — semantic colors (success/warning/danger)

The honest trade-off: brand coherence vs. universality.

Universal wins. "Red for danger, green for success, amber for warning" works across cultures that read Latin alphabets and in every ecosystem your product plugs into. If your brand is red and you try to make danger a different red, people miss errors. If your brand is green and you make success a different green, same problem.

Pick semantics from a neighboring hue family, not the brand:

  • Danger — hue 25°, moderate chroma (0.18). A warm red.
  • Warning — hue 70°, high chroma (0.19). An amber, not yellow.
  • Success — hue 150°, moderate chroma (0.14). A desaturated green.
  • Info — hue 230°, moderate chroma (0.17). A blue cooler than most brands.

Build the full 50–950 ramp for each. Use the 100/900 versions as muted backgrounds for toasts and alerts; use the 600/700 for the icon color. Use the 50/950 for the subtle hover state on a danger button.

Step 7 — write tokens, not hex codes, into components

The output of all this work is a token file that looks like:

```css :root { --surface: var(--neutral-50); --surface-raised: var(--neutral-0); --surface-muted: var(--neutral-100);

--text: var(--neutral-900); --text-secondary: var(--neutral-700); --text-tertiary: var(--neutral-500);

--brand-text: var(--brand-700); --brand-bg: var(--brand-600); --brand-on-bg: var(--neutral-0); }

@media (prefers-color-scheme: dark) { :root { --surface: var(--neutral-950); --surface-raised: var(--neutral-900); --surface-muted: var(--neutral-800);

--text: var(--neutral-50); --text-secondary: var(--neutral-200); --text-tertiary: var(--neutral-400);

--brand-text: var(--brand-300); --brand-bg: var(--brand-500); --brand-on-bg: var(--neutral-0); } } ```

Components never reach for --brand-500 directly. They reach for --brand-text. If the brand shifts, you re-point one variable instead of greping 200 components.

For semantic context around which colors signal what, running your palette through the Color Emotion Palette Generator can surface mismatches early — if your palette generator reports your "success" green as "melancholy", you've picked a hue that will read wrong to users regardless of what the token name claims.

Step 8 — the gradient and shadow test

Two cheap checks that reveal a bad palette:

Gradient test. Put brand-500 at 0% and brand-700 at 100% in a linear gradient. If there's a visible grey band in the middle, your chroma ramp is wrong — the midpoint collapsed. OKLCH ramps don't do this. HSL ramps often do.

Shadow test. Pure black shadow (rgba(0,0,0,0.1)) over a tinted surface looks grey. Tinted shadow matching your neutral hue family looks correct. If your neutrals are hue-less, you can't do this.

The CSS Gradient Code Generator is useful to lay out the middle-band test — generate between your 500 and 700 and inspect visually before committing the ramp.

What to skip

Things that waste design-system time:

  • A separate "accent" ramp with no defined use. Add it when you can name a component that needs it.
  • 500 lines of Figma variables mirroring the token file. Figma Variables are a copy, not the source. If the token file ships and Figma doesn't match, the token file wins. Fix the Figma, don't duplicate it.
  • A "glass" effect built into tokens. Make it a component. Tokens describe color, not translucency behavior.
  • AAA across the board. Already covered — body AA, large-text AA, document the exceptions.

The 90-minute version

If you're building a palette this afternoon and can't do the full workflow:

1. Pick a brand hex. Convert to OKLCH. (One tool call.) 2. Generate a 50–950 ramp with lightness-stepped OKLCH. (Script or any OKLCH-aware tool.) 3. Set --brand-text to the first ramp stop that hits 4.5:1 against your surface. (Usually 600 or 700.) 4. Clone the ramp for neutrals (tinted) and danger/warning/success/info (ramp of each hue). 5. Build the two surface/text token groups above. 6. Audit three real pages. Fix what fails.

The remaining polish — gradient test, dark-mode link tuning, semantic hue adjustments — is week-two work. Ship the token file first; iterate on the hard-to-see failures as they surface.

Reviewing an existing palette

If you're inheriting a palette rather than building one, the audit pattern is different. Three checks that surface 80% of problems in the first hour:

The role-coverage check. List every semantic role your product uses: body text, secondary text, link, link-hover, link-visited, button-primary-bg, button-primary-text, button-secondary-bg, button-secondary-text, input-border, input-focus-ring, success-bg, success-text, warning-bg, warning-text, danger-bg, danger-text, disabled, placeholder, divider. Open the token file. Count the ones that have an explicit assignment. If more than three roles share a token with no documented reason, that's palette debt — you'll find components using the same color for incompatible purposes.

The dark-mode parity check. For every token that has a light-mode value, is there a dark-mode value? Not a derived one — an explicit one? Palettes that use "dark mode = invert the hex" accumulate failures invisibly. Explicit per-mode tokens surface the places where the inversion would have broken.

The hard-coded-hex check. Grep your component files for # followed by six hex digits. Each hit is a place somebody short-circuited the token system. Count them. If the number is above zero and growing, the palette isn't actually governing anything — it's a suggestion. For a baseline palette to compare against when auditing, the Flat UI Color Palette Generator returns clean role-divided swatches you can use as a diff reference.

Living with the palette

A palette isn't done when you ship it. The next six months of use are what tell you whether the system holds.

Signs the palette is working: new components can be built without proposing new tokens; designers stop asking "what color should this be?" because the right choice is unambiguous from role; dark-mode variants come essentially for free.

Signs the palette is slipping: product teams add their own one-off colors (growing hex-code count); the "neutral-500" vs "neutral-600" question keeps resurfacing in design review (your gaps aren't right); accessibility bugs reappear even though the palette passes audits (your tokens aren't being used, individual hexes are).

The health metric worth tracking: ratio of hex codes referenced in components to tokens defined in the palette. In a healthy system that ratio is well under 1 — every component references tokens, not literal hex. The day it drifts above 1, somebody is bypassing the system and the palette's authority is slipping. Catch it in week one of the drift, not in month six.