Clean Markup

Perceptually Uniform Color Palettes with CSS oklch

Perceptually Uniform Color Palettes with CSS oklch

Design token architectures and UI theming engines have long relied on rgb() and hsl() to parameterize brand palettes. While hsl() (Hue, Saturation, Lightness) offered a more intuitive mental model than raw hexadecimal values, it suffers from an intractable flaw: it is not perceptually uniform. In standard HSL space, setting lightness to 50% produces wildly divergent visual brightness depending on the chosen hue angle. For example, yellow at hsl(60 100% 50%) appears blindingly radiant with a perceived luminance close to 90%, whereas blue at hsl(240 100% 50%) appears dark and subdued. This discrepancy breaks automated WCAG contrast calculations whenever a hue changes, forcing design systems to implement brittle, manual compensation tables for each brand color.

The CSS Color Module Level 4 introduces the oklch() functional notation, built on the perceptually uniform Oklab color model. Defined by Lightness (L), Chroma (C), and Hue (H), oklch() aligns color coordinates directly with human photoreceptor response. When lightness is held constant at 65%, every color around the entire 360-degree hue circle maintains identical perceived luminance to the human eye. Furthermore, oklch unlocks the wide color gamuts of modern hardware—such as Display P3 and Rec. 2020—rendering richer, more vivid tones than sRGB could ever express. This mathematical consistency renders oklch() the ideal foundation for algorithmic design tokens and accessible multi-brand theming.

Example

/* Comparing HSL perceptual discrepancy against OKLCH consistency */

:root {
  /* HSL Flaw: Yellow feels blinding, Blue feels dark despite identical 50% lightness */
  --hsl-yellow: hsl(60 100% 50%);  /* Perceived lightness is ~89% */
  --hsl-blue:   hsl(240 100% 50%); /* Perceived lightness is ~26% */

  /* OKLCH Solution: Both share identical perceived brightness and contrast ratios */
  --oklch-yellow: oklch(0.65 0.18 95);  /* Constant perceived lightness: 65% */
  --oklch-blue:   oklch(0.65 0.18 250); /* Constant perceived lightness: 65% */
}

/* White text yields identical, mathematically guaranteed contrast against both */
.badge-yellow { background: var(--oklch-yellow); color: #000000; }
.badge-blue   { background: var(--oklch-blue);   color: #000000; }

Implementation

/* ==========================================================================
   Perceptually Uniform Design Token System with CSS oklch()
   ========================================================================== */

:root {
  /* ------------------------------------------------------------------------
     1. Semantic Hue Angles (0 - 360 deg)
     ------------------------------------------------------------------------ */
  --hue-brand:     245; /* Deep Indigo / Sapphire */
  --hue-success:   142; /* Emerald Green */
  --hue-warning:    85; /* Amber / Gold */
  --hue-danger:     25; /* Coral Crimson */
  --hue-neutral:   240; /* Cool Slate */

  /* ------------------------------------------------------------------------
     2. Perceptual Lightness Scales (L: 0.0 to 1.0)
     ------------------------------------------------------------------------ */
  --l-surface-0:   0.99; /* Page background */
  --l-surface-1:   0.96; /* Card background */
  --l-surface-2:   0.92; /* Border / subtle element */
  --l-tint-soft:   0.88; /* Background badge tint */
  --l-tint-vivid:  0.60; /* Interactive accent fill */
  --l-text-muted:  0.45; /* Secondary text */
  --l-text-strong: 0.18; /* High-contrast body text */

  /* ------------------------------------------------------------------------
     3. Chroma Curves (C: 0.0 to ~0.35)
     ------------------------------------------------------------------------ */
  --c-neutral: 0.012; /* Desaturated surface chrome */
  --c-soft:    0.045; /* Subtle tint chrome */
  --c-accent:  0.170; /* Vivid P3-capable chromatic punch */

  /* ------------------------------------------------------------------------
     4. Algorithmic Palette Generation
     ------------------------------------------------------------------------ */
  /* Brand Tokens */
  --color-brand-soft:    oklch(var(--l-tint-soft) var(--c-soft) var(--hue-brand));
  --color-brand-solid:   oklch(var(--l-tint-vivid) var(--c-accent) var(--hue-brand));
  --color-brand-contrast: oklch(0.99 0.005 var(--hue-brand));

  /* Success Tokens */
  --color-success-soft:  oklch(var(--l-tint-soft) var(--c-soft) var(--hue-success));
  --color-success-solid: oklch(var(--l-tint-vivid) var(--c-accent) var(--hue-success));

  /* Warning Tokens (Retains identical perceived brightness without washed-out text) */
  --color-warning-soft:  oklch(var(--l-tint-soft) var(--c-soft) var(--hue-warning));
  --color-warning-solid: oklch(var(--l-tint-vivid) var(--c-accent) var(--hue-warning));

  /* Danger Tokens */
  --color-danger-soft:   oklch(var(--l-tint-soft) var(--c-soft) var(--hue-danger));
  --color-danger-solid:  oklch(var(--l-tint-vivid) var(--c-accent) var(--hue-danger));

  /* Neutral Surface & Text Hierarchy */
  --color-bg-base:       oklch(var(--l-surface-0) var(--c-neutral) var(--hue-neutral));
  --color-bg-surface:    oklch(var(--l-surface-1) var(--c-neutral) var(--hue-neutral));
  --color-border:        oklch(var(--l-surface-2) var(--c-neutral) var(--hue-neutral));
  --color-text-body:     oklch(var(--l-text-strong) var(--c-neutral) var(--hue-neutral));
  --color-text-subtle:   oklch(var(--l-text-muted) var(--c-neutral) var(--hue-neutral));
}

/* --------------------------------------------------------------------------
   5. Dark Mode Calibration
   Because OKLCH lightness is perceptually true, flipping the lightness scales
   guarantees identical contrast ratios without retuning chroma or hue angles.
   -------------------------------------------------------------------------- */
@media (prefers-color-scheme: dark) {
  :root {
    --l-surface-0:   0.14;
    --l-surface-1:   0.20;
    --l-surface-2:   0.28;
    --l-tint-soft:   0.26;
    --l-tint-vivid:  0.64;
    --l-text-muted:  0.68;
    --l-text-strong: 0.94;

    --c-neutral: 0.015;
    --c-soft:    0.060;
  }
}

/* --------------------------------------------------------------------------
   6. Accessible Component Application
   -------------------------------------------------------------------------- */
body {
  background-color: var(--color-bg-base);
  color: var(--color-text-body);
  font-family: system-ui, -apple-system, sans-serif;
  margin: 0;
  padding: 2rem;
}

.card-panel {
  background-color: var(--color-bg-surface);
  border: 1px solid var(--color-border);
  border-radius: 0.75rem;
  padding: 1.5rem;
  max-width: 600px;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.04);
}

.status-badge {
  display: inline-flex;
  align-items: center;
  gap: 0.35rem;
  padding: 0.25rem 0.65rem;
  border-radius: 9999px;
  font-size: 0.8125rem;
  font-weight: 600;
}

/* All badges share identical contrast ratio against their respective text */
.status-badge--success {
  background-color: var(--color-success-soft);
  color: oklch(0.35 var(--c-accent) var(--hue-success));
}

.status-badge--warning {
  background-color: var(--color-warning-soft);
  color: oklch(0.35 var(--c-accent) var(--hue-warning));
}

.status-badge--danger {
  background-color: var(--color-danger-soft);
  color: oklch(0.35 var(--c-accent) var(--hue-danger));
}

.btn-primary {
  display: inline-block;
  background-color: var(--color-brand-solid);
  color: var(--color-brand-contrast);
  padding: 0.625rem 1.25rem;
  border-radius: 0.5rem;
  font-weight: 600;
  text-decoration: none;
  border: none;
  cursor: pointer;
  transition: filter 0.15s ease;
}

.btn-primary:hover {
  /* Predictably boost lightness without altering hue tone or gamut boundary */
  filter: brightness(1.08);
}

Main Point: The perceptual uniformity of oklch() ensures constant perceived brightness across all hue angles, enabling reliable, automated contrast calculations and vibrant wide-gamut color palettes.

Credits

Photo by Biel Morro on Unsplash

← Home