Color Science
A deep dive into OKLCH, perceptual uniformity, delta-E, and gamut mapping — the foundations of Nightfall's transformations.
Why OKLCH?
HSL is not perceptually uniform. A 10-degree hue shift at one point on the wheel looks dramatically different than the same shift elsewhere. HSL "yellow" at 50% lightness appears far brighter than HSL "blue" at 50% lightness. This means HSL-based dark mode generators produce inconsistent results.
OKLCH (created by Bjorn Ottosson in 2020) solves this. Equal numerical changes in L, C, or H produce equal visual changes across the entire color space. When Nightfall shifts a color's lightness by 0.1, it looks like the same amount of change whether the color is red, blue, or green.
The Three Channels
- L (Lightness) — Ranges from 0 (pure black) to 1 (pure white). Unlike HSL lightness, OKLCH L is perceptually linear: L=0.5 genuinely looks like "medium brightness" to human eyes, regardless of hue.
- C (Chroma) — Saturation intensity, starting at 0 (pure gray). Higher values are more vivid. Unlike HSL saturation, OKLCH chroma is perceptually meaningful — a chroma of 0.1 looks equally saturated across all hues.
- H (Hue) — 0 to 360 degrees around the color wheel. Unlike HSL, equal angular distances in OKLCH feel equally different to the human eye.
OKLCH vs HSL: A Comparison
# HSL: "50% lightness" looks wildly different across hues
hsl(60, 100%, 50%) → Yellow → Appears VERY bright
hsl(240, 100%, 50%) → Blue → Appears quite dark
# OKLCH: L=0.7 looks equally bright across all hues
oklch(0.7 0.15 90) → Yellow → Moderate brightness
oklch(0.7 0.15 260) → Blue → Moderate brightness (same!)
# This is why OKLCH produces better theme transformations.
# When we set L=0.15 for a dark background, it looks equally
# dark regardless of the hue undertone chosen.Delta-E: Measuring Perceptual Distance
Delta-E (dE) measures the perceptual distance between two colors. Nightfall uses the OKLCH-based delta-E formula to verify that transformations stay within acceptable bounds:
function deltaE(color1, color2) {
// Both colors in OKLCH
const dL = color1.l - color2.l;
const dC = color1.c - color2.c;
// Hue difference needs special handling for the circular nature
let dH = color1.h - color2.h;
if (dH > 180) dH -= 360;
if (dH < -180) dH += 360;
// Convert hue difference to a linear distance
const hDist = 2 * Math.sqrt(color1.c * color2.c) *
Math.sin((dH * Math.PI) / 360);
return Math.sqrt(dL * dL + dC * dC + hDist * hDist);
}Nightfall uses delta-E for:
- Brand color budgets — Ensures brand colors stay within dE < 15 of their original values
- Auto-fix validation — Verifies that contrast fixes are minimal
- Roundtrip testing — Confirms that light-to-dark-to-light produces dE < 2.0
Delta-E Reference Scale
- 0 - 1 — Imperceptible difference
- 1 - 2 — Perceptible only through close comparison
- 2 - 5 — Noticeable at a glance
- 5 - 10 — Clearly different but same color family
- 10 - 15 — Different shade of the same hue
- 15+ — Starts to feel like a different color
Gamut Mapping
OKLCH can represent colors outside the sRGB gamut (the range of colors displays can show). When a transformation produces an out-of-gamut color, Nightfall uses binary search to find the maximum chroma that fits within sRGB:
function gamutMap(oklch) {
// Check if the color is within sRGB gamut
if (isInGamut(oklch)) return oklch;
// Binary search: reduce chroma until the color fits in sRGB
let lo = 0;
let hi = oklch.c;
let bestC = 0;
for (let i = 0; i < 20; i++) {
const mid = (lo + hi) / 2;
const test = { l: oklch.l, c: mid, h: oklch.h };
if (isInGamut(test)) {
bestC = mid;
lo = mid; // Can we push chroma higher?
} else {
hi = mid; // Need less chroma
}
}
return { l: oklch.l, c: bestC, h: oklch.h };
}
// This preserves the hue and lightness exactly,
// reducing only chroma (saturation) to fit.
// The result is the most vivid version of the
// color that sRGB can display.CSS Native OKLCH
Modern CSS supports OKLCH natively, which means Nightfall's output values work directly in stylesheets without conversion:
/* OKLCH in CSS — supported in all modern browsers */
.card {
background: oklch(0.17 0.005 240);
color: oklch(0.82 0.01 240);
border-color: oklch(0.30 0.005 240 / 0.5); /* with alpha */
}Browser support: Chrome 111+, Firefox 113+, Safari 15.4+. Use the --fallback hex option for older browsers.