Skip to content

Color System

A UI principle for coding agents. Also covers colors, palette, hsl, oklch.

Design Theory

Ditch Hex for HSL (or OKLCH)

  • HSL = Hue, Saturation, Lightness
  • Much easier to manipulate colors
  • Easy to create lighter/darker versions
  • Easy to shift hue while keeping saturation/lightness
  • OKLCH is even better for perceptual uniformity

You Need More Colors Than You Think

Don't limit yourself to primary and accent:

  • Need 8-10 shades of each color for flexibility

Color categories needed:

  • Greys (8-10 shades)
  • Primary color(s) (8-10 shades each)
  • Accent colors (8-10 shades each)
  • Semantic colors:
    • Red for errors/destructive (8-10 shades)
    • Yellow for warnings (8-10 shades)
    • Green for success/positive (8-10 shades)

Define Your Shades Up Front

  • Choose darkest and lightest shades first
  • Fill in the middle
  • You'll need more options in the middle than at the extremes

Recommended approach:

  • 50, 100, 200, 300, 400, 500, 600, 700, 800, 900
  • 9 shades gives good flexibility

Don't Let Lightness Kill Your Saturation

  • As you make colors lighter, increase saturation
  • As you make colors darker, decrease saturation
  • This keeps colors vibrant across the scale

Why:

  • Fully saturated light colors look fluorescent
  • Fully saturated dark colors look muddy

General rule:

  • Lightest shades: highest saturation
  • Middle shades: moderate saturation
  • Darkest shades: lowest saturation

Greys Don't Have to Be Grey

  • Pure grey (no hue) can feel lifeless
  • Add a slight tint of your primary color
  • Or add blue for a cooler look
  • Makes greys feel more cohesive with your design

Accessible Doesn't Have to Mean Ugly

  • WCAG recommends 4.5:1 contrast ratio for normal text
  • Don't just use pure black (#000) for text - too harsh
  • Use very dark grey or colored dark (maintains contrast while softer)
  • For colored backgrounds, hand-pick dark colors that pass contrast

Tips:

  • Flip background and text in high-contrast areas
  • Rotate hue for colored text on colored background

Watch Contrast Traps

  • Ghost text: placeholder the same color as the typed value, so users can't tell a filled field from an empty one. Make the placeholder lighter than the value.
  • Disabled too loud: a disabled control with near-active contrast still looks clickable. Drop it to ~30% opacity or a clearly-muted grey so it reads as inactive.
  • Subtle links: links barely different from body text are invisible. Add an underline or raise the contrast.

Don't Rely on Color Alone

  • Use color + another indicator (icon, label, position)
  • Important for accessibility
  • Makes interface clearer for everyone
  • Examples:
    • Errors: red + icon + error message
    • Success: green + checkmark + message

Keep Tinted Chips from Washing Out on Coloured Rows

  • A chip or badge with a light or semi-transparent fill (a muted pill, an outline badge) sitting on a row that gets a coloured hover highlight will bleach out. Its translucent fill alpha-composites toward the hover colour and loses its edge.
  • Fix it with mix-blend-mode: multiply on the chip. Multiply darkens the fill into the row colour instead of lightening it, so the chip stays legible and adopts the row tint. On a white or neutral surface multiply is a no-op (white x colour = colour), so plain contexts are unaffected.
  • Only apply it to light or translucent fills. An opaque coloured fill (a solid primary or destructive badge) multiplied against a coloured background produces a compound colour shift, a blue badge over an amber row reads brown-green, so leave those on normal blending.
  • Guard dark themes. Multiply on a dark surface crushes the chip toward black, so pair it with dark:mix-blend-normal and let the blend run in light mode only.
// ✅ Good – light/translucent chip that sits on coloured hover rows
<span className="rounded-full border border-border bg-muted/50 px-2.5 py-1 mix-blend-multiply dark:mix-blend-normal">
  Styled like Linear
</span>

// ✅ Good – shared Badge: scope the blend to the transparent outline variant only
outline: "text-foreground mix-blend-multiply dark:mix-blend-normal",

// ❌ Bad – blanket multiply on the shared Badge base also hits the opaque
//         default / secondary / destructive fills and muddies them

OKLCH Implementation

What is OKLCH?

OKLCH is a perceptually uniform color space where:

  • L (Lightness): 0 (black) to 1 (white)
  • C (Chroma): 0 (grayscale) to ~0.4 (highly saturated)
  • H (Hue): 0-360 degrees (color wheel)

Key advantage: Equal lightness steps look equally different to human eyes, unlike RGB/HSL.

Color Variables

Dark Theme:

:root {
  --primary: oklch(0.9 0.17 100);      /* Vibrant accent - high L, high C, yellow-orange H */
  --bg-dark: oklch(0.1 0 264);         /* Nearly black - low L, no C, blue H */
  --bg: oklch(0.2 0 264);              /* Dark gray - slightly higher L */
  --bg-light: oklch(0.3 0 264);        /* Lighter gray - continues progression */
  --text: oklch(0.96 0 264);           /* Near white - very high L */
  --text-muted: oklch(0.76 0 264);     /* Muted gray - mid-high L */
}

Light Theme:

body.light {
  --primary: oklch(0.65 0.15 264);     /* Purple accent - mid L, lower C */
  --bg-dark: oklch(0.92 0 264);        /* Light gray - inverted hierarchy */
  --bg: oklch(0.96 0 264);             /* Lighter gray */
  --bg-light: oklch(1 0 264);          /* Pure white */
  --text: oklch(0.15 0 264);           /* Near black */
  --text-muted: oklch(0.4 0 264);      /* Mid gray */
}

OKLCH Usage Rules

Creating new colors:

  • Keep hue consistent: Use 264 (blue-purple) for neutral colors
  • Use chroma=0 for grays: Keeps them perfectly neutral
  • Lightness hierarchy:
    • Dark theme: 0.1 → 0.2 → 0.3 (backgrounds), 0.76 → 0.96 (text)
    • Light theme: 0.92 → 0.96 → 1.0 (backgrounds), 0.4 → 0.15 (text)
  • Accent colors: Higher chroma (0.15-0.2) and different hue (100 for warm, 264 for cool)

Color contrast rules:

  • Minimum lightness difference: 0.5 for readability
  • Dark theme: 0.2 bg with 0.76+ text
  • Light theme: 0.96 bg with 0.4 text

Example - Creating a success color:

--success-dark: oklch(0.8 0.15 145);   /* Green hue, high lightness for dark theme */
--success-light: oklch(0.5 0.12 145);  /* Lower lightness for light theme */

Example - Creating hover states:

/* Move up the lightness hierarchy */
.card {
  background: var(--bg);              /* oklch(0.2 0 264) */
}
.card:hover {
  background: var(--bg-light);        /* oklch(0.3 0 264) */
}

Color Code Rules

When writing color code:

  • ALWAYS use oklch() format, never hex or rgb
  • Keep chroma at 0 for neutral grays
  • Maintain consistent hue (264) across theme
  • Test lightness contrast (minimum 0.5 difference)
  • Use CSS variables, don't hardcode values

Token Roles

A primitive is the raw value. A token is how that value gets used. Components read tokens only, never primitives: color: var(--color-text-secondary), never color: var(--blue-500). The primitive layer is where you tune the palette; the token layer is where the product states what each colour is for. Skip the token layer and every component becomes a place the redesign has to visit.

Name a token for its role, never for its appearance or its first use. --color-accent-solid survives a rebrand. --color-blue-button lies the moment the brand turns green, and --color-sidebar-gray lies the moment a second surface needs the same value. Ask what job the colour does, then name the job: error, muted, accent-solid.

Reserve accent for the brand colour. Left alone, primary drifts into meaning both the brand and the main body text, so a single rename breaks one of the two. Give the brand accent and leave primary for the dominant foreground, and each name keeps one meaning.

Never reuse a token from another role because it happens to be the right colour. A border token that matches the divider you need today is still a border token. When the border role changes, your divider changes with it, for a reason nobody can find. Add a token for the new role instead, even when the two values start out identical.

Every step in a palette needs a purpose. Give each step a job before you ship it: page background, component hover, border, solid fill, body text. The Algorithmic Palette Ramp below generates 50 through 950 mechanically, which is the right starting point and the wrong finishing point. Steps nothing uses still cost review time and still tempt someone into picking by eye. Drop them.

Theme Switching

Pick one mechanism and use it for every token. Either prefers-color-scheme reads the system setting, or a .dark class on the root reads an in-app choice. Mixing them leaves a subset of tokens answering to the operating system while the rest answer to the toggle, so the theme comes out half-flipped in exactly the case nobody tests: the user whose system says dark and whose toggle says light. Choose the class when the product offers its own switch, and the media query when it does not.

Disable all transitions while the theme changes. Every colour token changes at once, so any element carrying a colour transition animates its own private cross-fade, and the page dissolves in pieces over several hundred milliseconds instead of switching. Suppress transitions for the duration of the swap, then restore them.

/* Set while the theme flips, removed on the next frame. */
.theme-switching *,
.theme-switching *::before,
.theme-switching *::after {
  transition: none !important;
}
function setTheme(next) {
  document.documentElement.classList.add("theme-switching");
  document.documentElement.dataset.theme = next;
  // Two frames: one for the class to land, one for the paint that uses it.
  requestAnimationFrame(() =>
    requestAnimationFrame(() =>
      document.documentElement.classList.remove("theme-switching"),
    ),
  );
}

Color Details

Color relationships are easier to control when lightness, chroma, and hue can be reasoned about independently. oklch() makes those dimensions explicit and keeps perceived changes more even than channel-based mixing.

Build a palette around a consistent temperature. Darker shades may need slightly more chroma or a small hue shift to avoid looking dull. Translucent foregrounds inherit some of the surface beneath them, which can make a family of layers feel related. Always confirm contrast in the actual composited result.

For gradients, name the interpolation space and shape the stops to match perception. The space is a real choice with three answers, so make it deliberately rather than taking the default:

SpaceWriteUse it for
OKLabin oklabEven brightness across the ramp. The safe default for a fade between two hues.
OKLCHin oklchMore vivid middle tones, because hue travels the colour wheel instead of cutting through the middle.
sRGBno keywordThe classic muted midpoint. Pick it on purpose, not by forgetting the keyword.

Evenly spaced opacity stops can reveal bands or a visible “horizon”; eased stops distribute the transition more naturally.

.ambient-fade {
  background: linear-gradient(
    in oklch to bottom,
    oklch(62% 0.18 265 / 0) 0%,
    oklch(62% 0.18 265 / 0.06) 18%,
    oklch(62% 0.18 265 / 0.22) 42%,
    oklch(62% 0.18 265 / 0.56) 70%,
    oklch(62% 0.18 265 / 0.92) 100%
  );
}

.tinted-surface {
  background: oklch(97% 0.012 255 / 0.82);
  border: 1px solid oklch(20% 0.02 255 / 0.1);
  backdrop-filter: blur(18px) saturate(1.15);
}

Blend modes can add material richness, but isolate them inside a known layer stack and test light, dark, and high-contrast contexts.

Checklist

  • Work in HSL or OKLCH, not hex
  • Hand-pick colors for colored backgrounds (don't use opacity)
  • Add accent colors for interest
  • Ensure 4.5:1 contrast ratio for text
  • Use color + another indicator (not color alone)
  • Define 8-10 shades per color
  • Multiply-blend light/translucent chips that sit over coloured rows (guard dark with mix-blend-normal); never blanket-blend opaque badges

APCA Contrast (complements WCAG AA)

WCAG 2's 4.5:1 luminance ratio is still the legal bar for a formal conformance claim, so keep passing it. But its ratio is both too strict and too lenient depending on the pair, and it ignores polarity. APCA (Accessible Perceptual Contrast Algorithm) is more perceptually accurate and pairs naturally with oklch, since both are built on perceptual lightness. Use APCA to make the call, WCAG AA to satisfy compliance. They are complementary, not a swap.

APCA measures Lc (Lightness Contrast) between a foreground and the background it actually renders against. Identify that background first (usually the nearest parent's fill), then compare against the threshold for the content type:

ContentMinimumPreferred
Body text (columns/blocks)Lc 75Lc 90
Non-body text (labels, headlines)Lc 60Lc 75
Large text (≥ 36px)Lc 45Lc 60
UI components, disabled/placeholder textLc 30n/a

Lc 15 is the absolute floor for a non-text element to be discernible at all. Lc 30 doubles as the minimum for disabled and placeholder text.

Polarity is signed. A positive Lc means dark text on a light background, negative means light text on a dark background. Mirrored pairs do NOT score identically, so take the absolute value only when comparing against a threshold.

Light vs dark text crossover: L 0.73. On a neutral background, that oklch lightness is where black and white text swap which one wins:

if L > 0.73  → dark text on this background
if L <= 0.73 → light text on this background

The crossover sits higher than intuition suggests: in the 0.6 to 0.73 band the surface already looks light, yet white text still scores meaningfully higher than black.

Mid-lightness backgrounds cap contrast. On a background near L 0.75, even pure black text tops out around Lc 60, so body text always needs a background near the light or dark extreme. As a lightness-gap starting point for body text (targeting |Lc| ≥ 75): on a light background (L > 0.9) put the foreground below L 0.35; on a dark background (L < 0.25) put it above L 0.9. Adjust L first, keep C and H, then remeasure the rendered pair.

Gamut and Display P3

Every sRGB color fits inside Display P3, but not the reverse: P3 covers roughly 50% more colors. A color whose chroma exceeds the maximum for its lightness, hue, and space clips.

Max chroma varies by lightness AND hue. The gamut boundary is irregular. At L 0.5 in sRGB, purple (H ≈ 285) reaches C ≈ 0.29, red-orange (H ≈ 0 to 30) about C ≈ 0.20, and cyan (H ≈ 195) only C ≈ 0.09. The peak hue also moves with lightness: magenta peaks near L 0.7, green near L 0.9, and cyan stays lowest throughout. So there is no single safe max chroma, it depends on both L and H.

Clamp chroma to stay in sRGB. When a value clips, reduce C while holding L and H constant:

/* Out of sRGB gamut */
oklch(0.7 0.35 150)

/* Clamped to max chroma at this L and H */
oklch(0.7 0.22 150)

Progressive enhancement. Ship an in-gamut sRGB value as the base, then let wide-gamut displays opt into higher chroma:

/* sRGB-safe base, renders everywhere */
.accent {
  color: oklch(0.7 0.2 150);
}

/* Richer chroma on P3 displays */
@media (color-gamut: p3) {
  .accent {
    color: oklch(0.7 0.3 150);
  }
}

Both branches are oklch: color-gamut selects the wider palette, it does not test syntax support. Do not wrap this in an @supports (color: oklch()) gate whose fallback is also oklch, an engine without oklch support would drop every branch and render no color at all. A real syntax fallback needs a non-oklch value (sRGB hex or rgb), which this repo's oklch-only rule forbids, so target Baseline 2023 browsers (oklch shipped in all of them) and skip the syntax gate.

Match Chroma Percent Across Hues, Not Absolute Chroma

When a palette spans several hues (blue, green, red), give every hue the same lightness and the same chroma percentage (its share of that hue's own gamut maximum), not the same absolute C. Equal L guarantees equal perceived brightness. Equal C percent guarantees equal vividness relative to each hue's ceiling. Equal absolute C does not: because max chroma differs by hue, a fixed C value makes some hues look washed out and others fluorescent.

:root {
  /* Same L, same C% (80% of each hue's max): different absolute C */
  --blue-500:  oklch(0.623 0.141 250);   /* 80% of max 0.176 */
  --green-500: oklch(0.623 0.157 145);   /* 80% of max 0.196 */
  --red-500:   oklch(0.623 0.202 25);    /* 80% of max 0.253 */
}

Algorithmic Palette Ramp

To generate a full scale from one base color, hold hue fixed and vary lightness and chroma per step. Given a base L, a chroma percentage, and a hue H:

  1. Bound lightness. With delta = 0.4, set minL = max(0.05, baseL - delta) and maxL = min(0.95, baseL + delta). The clamp to about [0.05, 0.95] avoids pure black/white, which carry zero chroma.
  2. Distribute L evenly from maxL (lightest, label 50) down to minL (darkest, label 950).
  3. Clamp chroma per step to that step's gamut max: step.C = (chromaPercentage / 100) * findMaxChroma(step.L, H, colorSpace). Every step lands in gamut, and a high-chroma base correctly loses chroma at the lightest and darkest ends.
:root {
  --color-50:  oklch(0.971 0.012 250);
  --color-100: oklch(0.932 0.028 250);
  --color-200: oklch(0.882 0.048 250);
  --color-300: oklch(0.812 0.078 250);
  --color-500: oklch(0.623 0.188 250);
  --color-700: oklch(0.445 0.138 250);
  --color-800: oklch(0.362 0.108 250);
  --color-900: oklch(0.289 0.078 250);
  --color-950: oklch(0.215 0.048 250);
}

Do not mechanically mirror the scale for dark mode. Swap the semantic roles first, then re-tune: equal oklch steps do not preserve every foreground/background contrast pair, so recheck each pair and adjust the dark tokens independently.

Tailwind v4 @theme oklch Scale

Tailwind v4 defines its default palette in oklch, so custom themes follow suit. Declare the ramp in an @theme block and every step becomes a utility (bg-brand-500, text-brand-200, border-brand-700):

@theme {
  --color-brand-50:  oklch(0.971 0.012 250);
  --color-brand-100: oklch(0.932 0.028 250);
  --color-brand-200: oklch(0.882 0.048 250);
  --color-brand-300: oklch(0.812 0.078 250);
  --color-brand-400: oklch(0.722 0.148 250);
  --color-brand-500: oklch(0.623 0.188 250);
  --color-brand-600: oklch(0.535 0.168 250);
  --color-brand-700: oklch(0.445 0.138 250);
  --color-brand-800: oklch(0.362 0.108 250);
  --color-brand-900: oklch(0.289 0.078 250);
  --color-brand-950: oklch(0.215 0.048 250);
}

The slash-opacity modifier works directly on these tokens, no extra config:

<div class="bg-brand-500/50"></div>
<!-- compiles to oklch(0.623 0.188 250 / 0.5) -->

Alpha lands after the slash, matching the repo's oklch alpha convention. When migrating an existing theme, convert every @theme hex to oklch, replace theme() references that used hex, convert hardcoded hex in component code, and re-test dark mode since perceptual values shift slightly.

Use this guidance in your coding agent

Install the Better Design MCP once. Your agent then loads this page with one call.

get-ui-principle({ topic: "color" })
claude mcp add --scope user better-design --transport http https://better-design.com/api/mcp --header "Authorization: Bearer <YOUR_API_KEY>"
Browse related design systems