Skip to content
Terug naar blog
Tutorials

OKLCH-kleurruimte uitgelegd — waarom Tailwind v4 erop overstapte

Waarom OKLCH in 2024–2026 de standaard werd voor design systems. Hoe het verschilt van HSL en LCH, met een uitgewerkte HEX→OKLCH-conversie.

14 min leestijd

OKLCH-kleurruimte uitgelegd — waarom Tailwind v4 erop overstapte

Open de broncode van een willekeurig design system uit 2025 — shadcn/ui, Radix Themes, het standaardpalet van Tailwind v4 — en het eerste wat je opvalt zijn de kleuren. Geen hex-codes, geen hsl()-triplets, maar een functie waar drie jaar geleden niemand het over had: oklch(). Tailwind v4 levert zijn complete standaardpalet als OKLCH-literals. shadcn genereert nu thema’s die OKLCH custom properties uitspugen. Vercel bouwde in 2024 zijn design system erop opnieuw op.

Dit is geen hype. Er is een specifieke, wiskundige reden waarom elk serieus design system stilletjes van kleurmodel is gewisseld, en zodra je hem ziet, kun je niet meer terug naar het idee dat HSL ooit klopte voor waar we het voor gebruikten.

Deze post loopt die reden vanaf de basis door, eindigt met een uitgewerkte conversie van een hex-code naar OKLCH, en geeft je een migratierecept voor je eigen palet.

Toen kleurruimtes braken

Design systems hebben een tonale taak. Een knop hovert iets lichter dan zijn ruststaat. Een gedempte kaart zit één stap donkerder dan het oppervlak eromheen. Een focusring moet zichtbaar feller zijn dan het neutrale chrome erachter. Dit goed doen, op schaal, vereist dat “lichter” en “donkerder” hetzelfde betekenen over elke kleurtoon in je palet.

Die eis was makkelijk te negeren toen paletten acht kleuren en drie staten hadden. Het werd lastig toen teams 11-staps ramps (50–950 in Tailwind’s conventie) gingen leveren, acht semantische kleuren, lichte en donkere varianten, en merk-accenten die naast systeemkleuren van iOS, Android en het Web moesten kunnen bestaan. Plots was de vraag “is deze teal-500 even licht als onze blue-500” een echt engineering-probleem, geen art-direction-luxe.

HSL — het werkpaard sinds CSS 3 — kon hem niet beantwoorden. Twee HSL-kleuren met identieke L-waarden kunnen dramatisch verschillen in waargenomen helderheid. Een puur HSL-geel op lightness: 50% ziet er veel feller uit dan een puur HSL-blauw op dezelfde lightness. Je ogen nemen geel en blauw niet gelijk waar; HSL was ontworpen om intuïtief te zijn voor pickers, niet perceptueel consistent voor ramps. Tegen 2023 patchte elk design system dat verder schaalde dan een handvol kleuren hieromheen met custom mengscripts of handmatig getunede overrides.

Wat we nodig hadden was een kleurmodel waarin L daadwerkelijk “de waargenomen helderheid die een mens zou rapporteren” betekende, en waarin het draaien van de kleurtoon of het verlagen van de verzadiging niet als bijwerking de helderheid onzichtbaar verschoof. Dat model bestond al in de academische kleurwetenschap — het had alleen CSS nog niet bereikt.

Het HSL-probleem, concreet

Zet deze drie naast elkaar in een browser:

.a { background: hsl(60 100% 50%); }   /* yellow */
.b { background: hsl(240 100% 50%); }  /* blue */
.c { background: hsl(120 100% 50%); }  /* green */

Alle drie hebben L: 50%. Geen van drieën zien eruit alsof ze dezelfde lightness hebben. Het geel brandt bijna; het blauw leest haast zwart tegen een witte pagina; het groen zit ertussenin. Als je een hover-staat bouwt door 10% aan L toe te voegen, is de hover van het geel nauwelijks zichtbaar terwijl de hover van het blauw een dramatische verschuiving is. Je interactiepolish hangt uiteindelijk af van welke kleurtoon de ontwerper toevallig als startpunt had.

Dit is geen bug in HSL. HSL werd in 1978 ontworpen voor paint-by-numbers color pickers, waar gebruikers kleurtoon, verzadiging en “lightness” — gedefinieerd als (max(R,G,B) + min(R,G,B)) / 2 — zouden manipuleren om een kleur in te stellen. De wiskunde heeft geen notie van menselijke waarneming. Lightness in HSL is een geometrisch middelpunt van sRGB-kanalen, niets meer.

De CIE — het internationale standaardisatieorgaan voor colorimetrie — wist sinds de jaren ‘70 van dit probleem. Ze publiceerden twee perceptueel uniforme ruimtes, CIELAB en CIELUV, die lightness definieerden als iets dat dichter bij wat het menselijke zicht daadwerkelijk doet komt. Tegen de jaren ‘90 was CIE LAB de standaard in print, fotografie en kleurbeheer. Maar de conversie naar RGB is rommelig, en CSS nam het nooit op grote schaal over. Webontwikkelaars bleven HSL gebruiken, niet omdat het juist was maar omdat het er was.

CIE LAB / LCH: de academische fix, met eigen problemen

CIELAB neemt een XYZ-tristimuluswaarde (een model van hoe menselijke kegeltjes op licht reageren) en jaagt die door een kubieke wortel en een 2D-rotatie om drie kanalen te produceren: L* (lightness, 0–100), a* (groen ↔ rood), en b* (blauw ↔ geel). LCH is dezelfde ruimte in polaire vorm uitgedrukt: L*, C* (chroma, afstand vanaf neutraal), H* (kleurtoon-hoek).

Deze ruimtes zijn perceptueel uniform in meetbare zin. Een ΔE van 1 — een eenheidsstap in een willekeurige richting in LAB-ruimte — is ongeveer het kleinste kleurverschil dat een getrainde waarnemer kan detecteren. Print- en prepress-workflows draaien al decennia op LAB en LCH.

Dus waarom nam CSS LCH niet gewoon over en ging het door?

Twee redenen. Ten eerste werd CIE LAB gekalibreerd voor een specifieke kijkconditie (een 2°-standaardwaarnemer onder D50-belichting), geoptimaliseerd voor oppervlaktereflectie, niet voor lichtgevende displays. Op schermen drijft de perceptuele uniformiteit weg — kleuren die in LAB “even helder” zijn, zien er op een telefoon niet altijd even helder uit. Ten tweede is het LCH-gamut onhandig. Er zijn zichtbare kleuren die LAB goed beschrijft maar die buiten gangbare display-gamuts vallen, en de mapping van LCH naar sRGB produceert af en toe kleurtoon-verschuivingen (je blauw paars-t licht als je de chroma verlaagt). Voor design-system-werk zijn beide deal-breakers.

CSS Color 4 voegde in 2021 wel lab() en lch() toe, en die werken in moderne browsers. Maar voor het specifieke probleem van consistente tonale ramps bouwen op lichtgevende schermen, bleef de community zoeken.

OKLAB / OKLCH: Ottossons inzicht uit 2020

In december 2020 publiceerde Björn Ottosson — een Zweedse kleur-ingenieur — een paper met de titel “A perceptual color space for image processing.” De paper was klein: drie korte matrices, een stap met kubieke wortel, geen kalibratietabellen, geen auteursrechtelijk beschermde referentiedata. Ottosson nam de bestaande IPT- en CAM16-UCS-kleurmodellen — academische ruimtes met goede eigenschappen maar slechte wiskunde — en leidde een eenvoudigere ruimte af die hun perceptuele gedrag benaderde via gewone matrixvermenigvuldigingen op lineair-licht XYZ-tristimuluswaarden.

Hij noemde het OKLAB. De polaire vorm is OKLCH.

Wat OKLCH speciaal maakt is geen nieuwheid — het is fit for purpose. Drie eigenschappen samen:

  1. Lightness in OKLCH is echt perceptueel. Een puur geel op L: 0.7 en een puur blauw op L: 0.7 zien er op een gekalibreerde display even helder uit. Hover-staten gedefinieerd als L + 0.05 produceren visueel gelijkwaardige verschuivingen over het hele palet.
  2. Kleurtoon blijft behouden bij chroma-veranderingen. Als je de C-waarde van oklch(0.7 0.2 30) verlaagt naar oklch(0.7 0.1 30), blijft de kleurtoon staan. In LCH introduceert dezelfde operatie vaak een zichtbare kleurtoon-verschuiving. In OKLCH kun je chroma platdrukken om een gedempte variant van een merkkleur te bouwen zonder dat hij per ongeluk naar een andere kleurtoon afdrijft.
  3. De wiskunde is goedkoop. Twee matrixvermenigvuldigingen en één kubieke wortel. Te implementeren in een JavaScript-functie van 30 regels. Geen opzoektabellen, geen kalibratie per apparaat, geen licentiezorgen.

De combinatie is wat OKLCH bruikbaar maakte in echte CSS. Het W3C voegde oklch() in 2022 toe aan CSS Color 4. Chrome 111 leverde het in 2023. Elke evergreen-browser ondersteunde het tegen midden 2024. Tailwind v4 maakte het datzelfde jaar het standaard paletformaat.

De wiskunde: een uitgewerkte HEX → OKLCH-conversie

Laten we #3b82f6 doorlopen — Tailwind’s blue-500 — naar OKLCH. Dit is dezelfde wiskunde die de Color Converter-tool en de HEX to OKLCH-spoke bij elke toetsaanslag draaien. Weten wat er onder de motorkap gebeurt maakt de valkuilen in de volgende sectie logischer.

Stap 1: Hex naar sRGB. Splits de zes-cijferige hex in drie paren en deel elk door 255.

const r = 0x3b / 255; // 0.231
const g = 0x82 / 255; // 0.510
const b = 0xf6 / 255; // 0.965

Dit zijn gamma-gecodeerde sRGB-waarden: de kanaalwaarden zoals je beeldbestand ze opslaat, met een niet-lineaire curve ingebakken om te compenseren voor hoe monitoren licht uitstralen.

Stap 2: sRGB naar lineaire sRGB. Verwijder de gamma-curve zodat we lineair-licht kanaalwaarden hebben. De standaard stuksgewijze transformatie uit CSS Color 4 §11.2:

const linear = (v) => v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
const [lr, lg, lb] = [r, g, b].map(linear);
// lr ≈ 0.045, lg ≈ 0.220, lb ≈ 0.923

Stap 3: Lineaire sRGB naar XYZ D65. Een standaard matrixvermenigvuldiging, gedefinieerd in CSS Color 4 §15.1:

const x = 0.4124564 * lr + 0.3575761 * lg + 0.1804375 * lb;
const y = 0.2126729 * lr + 0.7151522 * lg + 0.0721750 * lb;
const z = 0.0193339 * lr + 0.1191920 * lg + 0.9503041 * lb;
// x ≈ 0.265, y ≈ 0.231, z ≈ 0.927

Dit is de canonieke XYZ-tristimulusrepresentatie — de “welke golflengten heeft deze kleur, uitgedrukt in respons van menselijke kegeltjes”-vorm.

Stap 4: XYZ naar LMS. Ottossons eerste matrix mapt XYZ naar een long/medium/short cone-fundamentals-ruimte afgestemd op OKLAB:

const lms = [
  0.8189330101 * x + 0.3618667424 * y - 0.1288597137 * z,
  0.0329845436 * x + 0.9293118715 * y + 0.0361456387 * z,
  0.0482003018 * x + 0.2643662691 * y + 0.6338517070 * z,
];

Stap 5: Kubieke wortel van de LMS-waarden. Dit is de perceptuele-compressiestap — analoog aan de kubieke wortel in CIE LAB:

const lms_ = lms.map(Math.cbrt);

Stap 6: LMS’ naar OKLAB. Ottossons tweede matrix:

const L = 0.2104542553 * lms_[0] + 0.7936177850 * lms_[1] - 0.0040720468 * lms_[2];
const a = 1.9779984951 * lms_[0] - 2.4285922050 * lms_[1] + 0.4505937099 * lms_[2];
const b_ = 0.0259040371 * lms_[0] + 0.7827717662 * lms_[1] - 0.8086757660 * lms_[2];
// L ≈ 0.629, a ≈ -0.022, b_ ≈ -0.191

Dat is OKLAB. Het lightness-kanaal L ≈ 0.629 is wat het oog waarneemt als “60% helderheid” voor dit specifieke blauw.

Stap 7: OKLAB naar OKLCH (Cartesisch naar polair).

const C = Math.sqrt(a * a + b_ * b_);           // 0.193
const H = (Math.atan2(b_, a) * 180 / Math.PI + 360) % 360; // 263.4
// → oklch(0.629 0.193 263.4)

Dus #3b82f6 is oklch(0.629 0.193 263.4). Lightness 0,629, chroma 0,193, kleurtoon 263,4°.

Als je vanuit deze kleur een 50–950-ramp bouwt door alleen L te variëren (0,95 omlaag naar 0,15 in 11 stappen), waarbij C en H vast worden gehouden, krijg je een palet waarin elke tint zichtbaar dezelfde kleurtoon heeft, en uniform vervaagt in lightness. Doe hetzelfde in HSL en de donkere tinten verschuiven naar paars terwijl de lichte tinten naar grijs gaan. Dát is de winst.

Display P3 + Rec2020: waarom OKLCH wide gamut ontsluit

OKLCH is onbegrensd. Anders dan HSL (afgekapt op sRGB) en zelfs LCH (gekalibreerd voor reflecterende oppervlakken), heeft OKLCH geen impliciet gamut. Je kunt oklch(0.7 0.25 30) schrijven en een levendig rood produceren dat binnen het kleurvolume van Display P3 valt maar buiten dat van sRGB. Op een recente iPhone of MacBook rendert hij. Op een oudere monitor snapt de browser hem automatisch naar de dichtstbijzijnde sRGB-representatie.

Dit is belangrijk omdat Apple, Samsung en het W3C eind 2010 allemaal wide-gamut hardware leverden. De MacBook Pro 14” / 16” met mini-LED levert P3 standaard. De iPhone 15 Pro rendert Display P3 in Safari. Android-flagships leveren Rec2020-panelen. Tegen 2025 is een substantieel deel van het design-system-verkeer op wide-gamut hardware die kleuren kan tonen die HSL/sRGB simpelweg niet kunnen uitdrukken.

OKLCH laat je die kleuren schrijven zonder er een aparte @media (color-gamut: p3)-declaratie aan vast te bouten. De browser handelt de fallback af. Je design system krijgt out of the box een “gebruik het felste rood dat het apparaat kan renderen”.

Dit is ook waarom OKLCH het juiste formaat is voor design tokens. Een --brand-variabele in OKLCH is een apparaat-onafhankelijke beschrijving van intentie. De browser zoekt uit wat hij moet renderen op welke display de gebruiker ook heeft, en je code is portable over CSS, SwiftUI (dat Display P3 Color native ondersteunt), Android Compose (Rec2020-bewust), en Flutter.

Tailwind v4 en de design-token-revolutie

Tailwind v4 — uitgebracht in 2024 — was het kantelpunt dat OKLCH veranderde van onderzoek in industriële standaard. De auteurs van Tailwind maakten drie opiniated keuzes:

  1. Het standaardpalet is OKLCH. Slate, gray, zinc, neutral, stone — elke Tailwind-kleur is in de broncode gedefinieerd in oklch(). De 50–950-ramps zijn van constructie uniform in perceptuele lightness.
  2. Custom thema’s gebruiken @theme-blokken met OKLCH-literals. Merkkleuren worden gedefinieerd als oklch()-tokens; downstream utilities (bg-brand-500, text-brand-300) worden gegenereerd.
  3. Geen fallback-ceremonie nodig. Browsers zonder OKLCH-ondersteuning vallen onder de gedocumenteerde baseline van Tailwind v4.

Die laatste beslissing was degene die adoptie tot een footgun-vrije keuze maakte. Nog in 2023 moesten ontwerpers van elke kleur zowel oklch()- als hsl()-versies leveren, anders zouden oudere Safari-versies stuk gaan. Met Tailwind v4 is de baseline browsers vanaf 2023 of nieuwer, en OKLCH werkt overal.

De themagenerator van shadcn/ui volgt hetzelfde patroon: voer je merkkleur in, krijg een OKLCH-ramp eruit. Vercels design system gebruikt OKLCH voor zijn semantische kleuren. De kleurschalen van Radix Themes zijn gedefinieerd in OKLCH. De community is geconvergeerd.

Praktische migratie: HEX-palet → OKLCH-palet

Heb je vandaag een hex-gebaseerd palet, dan is de migratie mechanisch. Het recept:

1. Bepaal je ramp-structuur. Tailwind’s 50–950 (11 stops) is de de-facto-standaard en het waard om te volgen tenzij je een specifieke reden hebt om het niet te doen. Stops bij L = 0,97, 0,93, 0,86, 0,76, 0,63, 0,50, 0,42, 0,34, 0,26, 0,18, 0,10 geven een soepele perceptuele ramp.

2. Converteer je merk-hex naar OKLCH. Gebruik de Color Converter of HEX to OKLCH. Je krijgt een triplet zoals oklch(0.629 0.193 263.4). Noteer de H-waarde — dit is je merk-kleurtoon.

3. Houd C en H constant; varieer L. Bouw de ramp door dit uit te geven:

--brand-50:  oklch(0.97 0.193 263.4);
--brand-100: oklch(0.93 0.193 263.4);
...
--brand-950: oklch(0.10 0.193 263.4);

4. Tune de extreme stops. Bij zeer lage L (≤ 0,20) en zeer hoge L (≥ 0,95) vallen hoge chroma-waarden buiten sRGB. Verlaag C voor die stops, of accepteer de auto-snap van de browser. De defaults van Tailwind verlagen chroma richting beide uiteinden — kopieer dat patroon.

5. Definieer semantische aliassen. --surface: var(--brand-50), --surface-elevated: var(--brand-100), --text-primary: var(--brand-900), enz. Nu lezen je design tokens als intentie, niet als kleuren.

6. Verifieer contrast. Gebruik APCA Lc of WCAG 2 contrast-ratio’s om te bevestigen dat elk --text-*-paar tegen elk --surface-*-paar je toegankelijkheidsdrempel haalt. Omdat L in OKLCH perceptueel is, is de contrastwiskunde betrouwbaarder dan in HSL.

Een team dat deze migratie uitvoert op een legacy-palet van 60 kleuren, landt doorgaans in één middag een kleiner, uniformer OKLCH-palet van 30–40 tokens. Het nieuwe palet levert kleiner uit, genereert kleinere CSS, en produceert zichtbaar betere tonale beweging in hover- en disabled-staten zonder extra tuning.

Valkuilen en hoe je ermee omgaat

Een paar dingen om vooraf te weten:

Buiten-gamut-waarschuwingen. Sommige OKLCH-waarden vallen buiten Display P3 of sRGB. Moderne browsers regelen de snap naar de dichtstbijzijnde geldige kleur automatisch, maar de snap is verliesgevend: jouw oklch(0.7 0.25 30) rendert mogelijk iets minder verzadigd dan je hem schreef. Tools zoals de gamut-rij van de Color Converter vertellen je of je kleur sRGB-veilig, P3-veilig of Rec2020-veilig is, en bieden een snap-naar-sRGB met één klik, zodat wat je schrijft is wat je ziet.

Sub-pixel chroma-rariteiten. OKLCH-chroma is onbegrensd, maar het nuttige bereik is grofweg 0 tot ~0,4 voor zichtbare kleuren. Waarden boven 0,4 zijn alleen haalbaar met monochromatisch laserlicht — dat zijn geen fysieke kleuren die een display kan renderen. De Color Converter kapt de chroma-slider om die reden af op 0,4; waarden daarboven leveren geen waarneembaar verschil op een echte display.

Browser-ondersteuning, 2025-stijl. Chrome 111+, Safari 15.4+, Firefox 113+ ondersteunen oklch() allemaal native. Browsers van vóór 2023 niet. Moet je legacy IE/Edge of oudere mobiele Safari ondersteunen (1–3% van het verkeer, afhankelijk van je publiek), dan kun je een OKLCH-declaratie koppelen aan een hex-fallback via @supports (color: oklch(0 0 0)) — maar voor design-system-tokens die in 2025 worden uitgeleverd, weegt de kost van de fallback vaak niet op tegen het legacy-voordeel.

Hex-codes blijven bestaan. OKLCH is voor design-system intentie. Je CMS heeft mogelijk nog een hex-waarde nodig om legacy-redenen (e-mailhandtekeningen, Office-documenten, brand-asset-checklists). Houd een gegenereerde lookup-tabel die de sRGB-gesnapte hex per OKLCH-token uitspuwt, maar schrijf niet in hex.

Verwar OKLCH en OKLAB niet. OKLAB is de rechthoekige vorm (L, a, b-kanalen); OKLCH is dezelfde kleurruimte in polaire vorm (L, C, H). Je converteert tussen de twee met één Cartesische↔polaire stap. Gebruik OKLCH voor tokens (leesbaarder, makkelijker te rampen); gebruik OKLAB intern als je kleuren moet interpoleren of mengen.

Probeer het op je eigen palet

De snelste manier om wat we hier hebben beschreven in actie te zien, is een merk-hex in de Color Converter droppen. Typ je merkkleur in het HEX-veld en lees de OKLCH-output. Verschuif daarna de sliders aan de OKLCH-kant en kijk hoe dezelfde kleurtoon dezelfde kleurtoon blijft als je chroma verlaagt, en hoe dezelfde lightness dezelfde lightness blijft als je de kleurtoon draait. Na een paar minuten heb je een intuïtief gevoel voor waarom HSL altijd het verkeerde gereedschap was voor tonale ramps, en waarom elk serieus design system is overgestapt.

Voor de specifieke HEX-naar-OKLCH-conversie, gebruik HEX to OKLCH — dezelfde wiskunde als dit artikel, met één extra voordeel: het toont je de gamut-classificatie (sRGB / Display P3 / Rec2020), zodat je weet welke van je merkkleuren overal veilig zijn versus welke een wide-gamut-apparaat nodig hebben om volledig te renderen.

Dat is OKLCH. De migratie waard. Goed gedaan, schrijf je nooit meer hsl().

Gerelateerde artikelen

Alle artikelen bekijken