kyle.berry
All components

Component · Jul 4, 2026

OKLCH ramp explorer

Pick a chroma and hue, get a ten-step lightness ramp with live WCAG contrast ratios and copy-ready oklch() values.

OKLCH ramp explorer

click a step to copy · contrast is vs. its own text color · “clipped” = outside sRGB

component.tsx
'use client'

import { useRef, useState } from 'react'

/**
 * An OKLCH ramp explorer: pick chroma and hue, get a lightness ramp with
 * live WCAG contrast ratios (computed by converting OKLCH to sRGB by hand)
 * and copy-ready oklch() values. Out-of-gamut steps are flagged, not hidden.
 */

const LIGHTNESS_STEPS = [0.25, 0.33, 0.41, 0.49, 0.57, 0.65, 0.73, 0.81, 0.89, 0.96]

interface Rgb {
  r: number
  g: number
  b: number
  inGamut: boolean
}

/** OKLCH -> linear sRGB (Björn Ottosson's OKLab matrices). */
function oklchToLinearRgb(L: number, C: number, H: number): Rgb {
  const hRad = (H * Math.PI) / 180
  const a = C * Math.cos(hRad)
  const b = C * Math.sin(hRad)

  const l_ = L + 0.3963377774 * a + 0.2158037573 * b
  const m_ = L - 0.1055613458 * a - 0.0638541728 * b
  const s_ = L - 0.0894841775 * a - 1.291485548 * b

  const l = l_ ** 3
  const m = m_ ** 3
  const s = s_ ** 3

  const r = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s
  const g = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s
  const bl = -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s

  const eps = 0.0005
  const inGamut = [r, g, bl].every((c) => c >= -eps && c <= 1 + eps)
  const clamp = (c: number) => Math.min(1, Math.max(0, c))
  return { r: clamp(r), g: clamp(g), b: clamp(bl), inGamut }
}

function toGamma(c: number): number {
  return c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055
}

function toCss({ r, g, b }: Rgb): string {
  const to255 = (c: number) => Math.round(toGamma(c) * 255)
  return `rgb(${to255(r)} ${to255(g)} ${to255(b)})`
}

/** WCAG relative luminance from linear sRGB. */
function luminance({ r, g, b }: Rgb): number {
  return 0.2126 * r + 0.7152 * g + 0.0722 * b
}

function contrastRatio(y1: number, y2: number): number {
  const [hi, lo] = y1 > y2 ? [y1, y2] : [y2, y1]
  return (hi + 0.05) / (lo + 0.05)
}

function wcagTier(ratio: number): string {
  if (ratio >= 7) return 'AAA'
  if (ratio >= 4.5) return 'AA'
  if (ratio >= 3) return 'AA lg'
  return 'fail'
}

export default function OklchExplorer() {
  const [chroma, setChroma] = useState(0.12)
  const [hue, setHue] = useState(145)
  const [copiedStep, setCopiedStep] = useState<number | null>(null)
  const copyTimer = useRef(0)

  const copy = (value: string, step: number) => {
    void navigator.clipboard
      .writeText(value)
      .then(() => {
        setCopiedStep(step)
        window.clearTimeout(copyTimer.current)
        copyTimer.current = window.setTimeout(() => setCopiedStep(null), 1200)
      })
      .catch(() => {})
  }

  return (
    <div className="mx-auto w-lg max-w-full">
      <div className="grid grid-cols-2 gap-6">
        <label className="block">
          <span className="flex items-baseline justify-between font-mono text-[10px] tracking-[0.14em] text-(--color-fg-subtle) uppercase">
            Chroma <span className="text-(--color-fg-muted)">{chroma.toFixed(3)}</span>
          </span>
          <input
            type="range"
            min={0}
            max={0.3}
            step={0.005}
            value={chroma}
            onChange={(e) => setChroma(Number(e.target.value))}
            className="mt-2 w-full accent-(--color-accent)"
          />
        </label>
        <label className="block">
          <span className="flex items-baseline justify-between font-mono text-[10px] tracking-[0.14em] text-(--color-fg-subtle) uppercase">
            Hue <span className="text-(--color-fg-muted)">{hue}°</span>
          </span>
          <input
            type="range"
            min={0}
            max={360}
            step={1}
            value={hue}
            onChange={(e) => setHue(Number(e.target.value))}
            className="mt-2 w-full accent-(--color-accent)"
          />
        </label>
      </div>

      <ul className="mt-6 overflow-hidden rounded-(--radius-card) border border-(--color-border)">
        {LIGHTNESS_STEPS.map((L) => {
          const rgb = oklchToLinearRgb(L, chroma, hue)
          const y = luminance(rgb)
          const vsWhite = contrastRatio(y, 1)
          const vsBlack = contrastRatio(y, 0)
          const textOnSwatch = vsWhite >= vsBlack ? '#fff' : '#000'
          const ratio = Math.max(vsWhite, vsBlack)
          const value = `oklch(${L.toFixed(2)} ${chroma.toFixed(3)} ${hue})`

          return (
            <li key={L}>
              <button
                type="button"
                onClick={() => copy(value, L)}
                style={{ background: toCss(rgb), color: textOnSwatch }}
                className="flex w-full items-center justify-between px-4 py-2.5 text-left font-mono text-xs"
              >
                <span>
                  {copiedStep === L ? 'Copied!' : value}
                  {!rgb.inGamut ? <span title="outside sRGB, clamped"> · clipped</span> : null}
                </span>
                <span>
                  {ratio.toFixed(2)}:1 · {wcagTier(ratio)}
                </span>
              </button>
            </li>
          )
        })}
      </ul>

      <p className="mt-3 font-mono text-[10px] text-(--color-fg-subtle)">
        click a step to copy · contrast is vs. its own text color · “clipped” = outside sRGB
      </p>
    </div>
  )
}

Every color scale on this site is written in OKLCH, and this little tool is why. In OKLCH, the lightness channel actually means lightness: two colors with the same L read as equally bright regardless of hue. Build a ten-step ramp in HSL and the yellow steps glow while the blue steps go muddy. Build the same ramp in OKLCH and the steps stay evenly spaced, which is the entire job of a ramp.

The explorer holds chroma and hue fixed and walks lightness from 0.25 to 0.96, which is how I actually construct scales: pick the hue, pick how saturated the family should be, and let lightness do the stepping. Drag the hue slider across its full range and watch the ramp keep its visual rhythm. That consistency is the property you are buying with OKLCH.

The contrast readout is the practical half. Each step converts to sRGB by hand (the OKLab matrices are about fifteen lines of code) and computes a WCAG relative luminance from the linear values, then reports the ratio against whichever text color the swatch itself chose, white or black. The tier labels follow the spec: 4.5:1 for normal text, 3:1 for large text, 7:1 for AAA. Watching the AA boundary move as you drag chroma teaches you more about contrast than any table of hex codes did for me.

One honest wrinkle: OKLCH can describe colors that sRGB cannot display. Push chroma high and some steps leave the gamut, at which point this component clamps the channels and labels the step "clipped." Real CSS engines do something smarter than clamping (gamut mapping toward lower chroma), but I wanted the failure visible rather than silently prettified, because a clipped step means your token will not render as the math suggests on most displays today.

The copy interaction mirrors the token explorer elsewhere in this catalog: click a step, get the oklch() string on your clipboard, paste it straight into a CSS custom property.

Related

Token explorerdesign-systems