Skip to main content

11

Color

Hex grays look different against dark backgrounds than light ones. oklch() fixes that. color-mix() lets you derive every muted value from one token.

6 min read

You inverted the palette for dark mode. The gray text that was #63635e on white became #969696 on dark. You tested it, it looked fine. Two weeks later you noticed the secondary text glows slightly on black backgrounds, and the gray you chose for borders is almost indistinguishable from the background on certain displays.

Hex and hsl() aren't . A gray that looks mid-tone on white can look bright on a dark background, because brightness perception depends on surrounding context, and hsl lightness doesn't account for that. oklch does.

oklch

oklch() uses three values: L (lightness, 0 to 1), C (, 0 to 0.4), H (hue, 0 to 360). Its lightness channel is perceptually linear. A color at L=0.5 looks equally bright on light and dark backgrounds.

oklch Lightness

Secondary text, captions, and metadata — the supporting cast of any typographic hierarchy.

Secondary text, captions, and metadata — the supporting cast of any typographic hierarchy.

Loklch(0.50 0.01 80) · ≈ #bababa

Drag L from 0 to 1. The same value appears on both a white and dark background simultaneously. Notice how L=0.5 reads as a mid-tone gray on both — not too dark or too washed out in either context. That's perceptual linearity. The same L=0.43 value works for secondary text on both themes without adjustment.

:root {
/* Light mode: dark on white */
--color-text-primary: oklch(0.12 0.005 80);
--color-text-secondary: oklch(0.43 0.01 80);
--color-text-tertiary: oklch(0.48 0.01 80);
}

:root[data-theme="dark"] {
/* Dark mode: light on near-black */
--color-text-primary: oklch(0.92 0.005 80);
--color-text-secondary: oklch(0.65 0.01 80);
--color-text-tertiary: oklch(0.60 0.01 80);
}
:root {
/* Light mode: dark on white */
--color-text-primary: oklch(0.12 0.005 80);
--color-text-secondary: oklch(0.43 0.01 80);
--color-text-tertiary: oklch(0.48 0.01 80);
}

:root[data-theme="dark"] {
/* Dark mode: light on near-black */
--color-text-primary: oklch(0.92 0.005 80);
--color-text-secondary: oklch(0.65 0.01 80);
--color-text-tertiary: oklch(0.60 0.01 80);
}

The Rule

Define text colors in oklch. Use the same hue and a low chroma for all neutral grays.

The small chroma value (0.005 to 0.01) gives grays a faint warm or cool tint that matches the surrounding palette. Pure oklch(0.5 0 0) is a neutral gray; slightly warm or cool grays sit more naturally in most palettes.

oklch() is supported in Chrome 111+, Firefox 116+, Safari 16.4+. Older browsers fall back to initial, so set a fallback hex before the oklch value.

:root {
--color-text-secondary: #63635e;
--color-text-secondary: oklch(0.43 0.01 80);
}
:root {
--color-text-secondary: #63635e;
--color-text-secondary: oklch(0.43 0.01 80);
}

color-mix

color-mix() blends two colors in a given color space. The most practical use: deriving muted text, borders, and hover states from one source token without adding new hex values.

a {
text-decoration-color: color-mix(
  in srgb,
  currentColor 35%,
  transparent
);
}

.button:hover {
background: color-mix(
  in srgb,
  var(--color-text-tertiary) 6%,
  transparent
);
}

.divider {
border-color: color-mix(
  in srgb,
  var(--color-text-primary) 10%,
  transparent
);
}
a {
text-decoration-color: color-mix(
  in srgb,
  currentColor 35%,
  transparent
);
}

.button:hover {
background: color-mix(
  in srgb,
  var(--color-text-tertiary) 6%,
  transparent
);
}

.divider {
border-color: color-mix(
  in srgb,
  var(--color-text-primary) 10%,
  transparent
);
}

When the base token changes for dark mode, every derived value changes with it. You don't need a separate --color-border-dark token.

Common Mistake

Defining a new hex value for every opacity variant: --color-border: #e8e8e8, --color-border-strong: #cccccc, --color-border-subtle: #f0f0f0. Three tokens where color-mix() would give you all three from one.

light-dark()

light-dark() lets a single custom property hold both a light and dark value. The browser picks based on color-scheme.

:root {
color-scheme: light dark;
}

.badge {
background: light-dark(oklch(0.95 0.01 80), oklch(0.22 0.01 80));
color: light-dark(oklch(0.2 0.01 80), oklch(0.88 0.01 80));
}
:root {
color-scheme: light dark;
}

.badge {
background: light-dark(oklch(0.95 0.01 80), oklch(0.22 0.01 80));
color: light-dark(oklch(0.2 0.01 80), oklch(0.88 0.01 80));
}

This works without JavaScript theme toggling when you rely on the system prefers-color-scheme. For a JS-toggled theme (like Grade's), keep the data-theme attribute approach on :root, which gives you explicit control.

light-dark() is supported in Chrome 123+, Firefox 120+, Safari 17.5+.