Home / Snippets / Color & Theming /

Brand color in OKLCH

Define a single brand hue with --brand-h and --brand-c, then generate a full 50–900 palette by varying only the lightness in oklch().

50 oklch(0.96 …)
100 oklch(0.90 …)
200 oklch(0.82 …)
300 oklch(0.72 …)
400 oklch(0.62 …)
500 oklch(0.52 …) base
600 oklch(0.43 …)
700 oklch(0.35 …)
900 oklch(0.25 …)
Widely Supported
colorno-js

Quick implementation

:root {
  /* Brand hue and chroma — change these two values
     to retheme the entire palette instantly */
  --brand-h: 265;    /* hue angle, 0–360 */
  --brand-c: 0.22;   /* chroma, 0–0.37 (sRGB safe ~0.15) */
}

/* Tints (light) */
.brand-50  { background: oklch(0.96 calc(var(--brand-c) * 0.35) var(--brand-h)); }
.brand-100 { background: oklch(0.90 calc(var(--brand-c) * 0.55) var(--brand-h)); }
.brand-200 { background: oklch(0.82 calc(var(--brand-c) * 0.70) var(--brand-h)); }
.brand-300 { background: oklch(0.72 var(--brand-c) var(--brand-h)); }

/* Mid-range */
.brand-400 { background: oklch(0.62 var(--brand-c) var(--brand-h)); }
.brand-500 { background: oklch(0.52 var(--brand-c) var(--brand-h)); } /* base */
.brand-600 { background: oklch(0.43 var(--brand-c) var(--brand-h)); }

/* Shades (dark) */
.brand-700 { background: oklch(0.35 var(--brand-c) var(--brand-h)); }
.brand-900 { background: oklch(0.25 calc(var(--brand-c) * 0.70) var(--brand-h)); }

/* Semantic aliases */
:root {
  --brand:         oklch(0.52 var(--brand-c) var(--brand-h));
  --brand-light:   oklch(0.72 var(--brand-c) var(--brand-h));
  --brand-dark:    oklch(0.35 var(--brand-c) var(--brand-h));
  --brand-surface: oklch(0.96 calc(var(--brand-c) * 0.35) var(--brand-h));
}

Prompt this to your LLM

Includes role, goal, constraints, framework variants, and edge cases to handle.

You are a senior design-systems engineer building a brand color palette
in CSS using oklch().

Goal: Generate a full 50–900 tint/shade scale from a single brand hue.
The palette must be themeable by changing only two CSS custom properties:
--brand-h (hue angle) and --brand-c (chroma).

Technical constraints:
- Use oklch(L C H) for every color value — no hex, hsl, or rgb.
- Define --brand-h and --brand-c on :root.
- Generate steps at lightness values: 0.96, 0.90, 0.82, 0.72, 0.62,
  0.52, 0.43, 0.35, 0.25 — these map to scale steps 50–900.
- For tints (steps 50–200), reduce chroma with calc(var(--brand-c) * factor)
  so very light swatches stay subtle rather than washed-out.
- For the darkest shades (800–900), also reduce chroma slightly to keep
  the color from going muddy.
- Add semantic aliases: --brand, --brand-light, --brand-dark,
  --brand-surface using the appropriate scale stops.
- Do NOT hard-code any color values — everything flows from --brand-h
  and --brand-c.

Framework variant (pick one):
A) Plain CSS custom properties on :root, usable anywhere.
B) PostCSS plugin — reads a brand hex from config, converts to oklch()
   automatically, and outputs the full scale as custom properties.

Edge cases to handle:
- High chroma values (> 0.20) may exceed the sRGB gamut at high
  lightness steps. Add a comment explaining that browsers clamp
  out-of-gamut colors automatically, or use @media (color-gamut: p3)
  to serve wider values to capable displays.
- When --brand-c is set to 0, the palette degrades gracefully to a
  neutral grey scale — useful for disabled or skeleton states.
- Ensure sufficient contrast between text and background using the
  palette: lightness 0.25 text on lightness 0.90+ backgrounds passes
  WCAG AA automatically due to oklch perceptual uniformity.

Return CSS custom properties only (or a PostCSS plugin if variant B).

Why oklch lightness is perceptually linear

In hsl(), equal steps in the L channel do not produce equal perceived brightness differences. A step from hsl(265 70% 50%) to hsl(265 70% 60%) looks very different from a step between two greens at the same numerical distance — the eye is far more sensitive to some hues than others. This means a palette built in HSL requires manual tweaking of each stop to look visually balanced.

OKLCH is built on the Oklab color space, which is derived from human vision research. Its lightness channel — the L in oklch(L C H) — is calibrated so that equal numerical steps produce equal perceived brightness changes regardless of hue. A palette from oklch(0.25 …) to oklch(0.96 …) in even increments will look evenly spaced to the human eye across every hue, including the notoriously tricky yellows and greens. This means the nine lightness values in the snippet above generate a visually balanced 50–900 scale with zero manual adjustment — just change --brand-h.

Generating a 50–900 scale from one hue

The palette system stores only two facts about the brand color: --brand-h (which direction on the color wheel) and --brand-c (how vivid). Every swatch is then expressed as oklch(L var(--brand-c) var(--brand-h)) with a fixed lightness value. Changing just those two variables repaints the entire design.

Tints at steps 50–200 deliberately scale down the chroma using calc(var(--brand-c) * 0.35) and similar multipliers. Very light colors with full chroma look neon and harsh; reducing chroma at high lightness keeps them feeling airy. The same reduction applies at step 900 in the other direction, where full chroma at near-black lightness can read as muddy. Steps 300–700 use the full --brand-c value — this is where the brand character is most visible.

The semantic aliases (--brand, --brand-light, --brand-dark, --brand-surface) map scale stops to roles. Components reference the semantic tokens, not the numbered stops — so if the design team decides the interactive state should shift from step 500 to step 600, only the alias needs updating.

Gamut clamping and wide-gamut displays

OKLCH can express colors outside the sRGB gamut — values with chroma above roughly 0.15 at mid-lightness may be out of gamut on standard displays. Browsers handle this gracefully: they automatically clamp out-of-gamut oklch values to the nearest in-gamut color. The visual difference is subtle — the color will look slightly less vivid than specified, but it will never break or show an error.

For displays capable of the P3 wide gamut (most modern MacBooks, iPhones, and high-end monitors), you can serve the full vivid color inside a @media (color-gamut: p3) block and fall back to a sRGB-safe value for other displays. The snippet above uses --brand-c: 0.22, which will be clamped on sRGB displays but renders fully vivid on P3 hardware — a pleasant progressive enhancement at no extra effort.

One practical tip: if the brand color is specified in hex (e.g. from a design file), convert it to oklch using the browser DevTools color picker or a tool like oklch.com. Take the resulting H and C values, set them as --brand-h and --brand-c, and the scale will be calibrated to the exact brand hue.