Dynamic color theming with OKLCH
Most design systems begin with a palette somebody curated. That assumption stops working when color arrives at runtime: a workspace brand, a color sampled from an image, a team preference, or a value returned by an API.
At that point color is no longer a token. It is untrusted input.
Dropping that input into --primary is easy. Letting it influence page
backgrounds, cards, borders, text, and focus states without flattening the visual
hierarchy is the real problem. A dark blue and a bright yellow cannot be allowed
to drag every surface to their own brightness.
The useful move is to separate what can change from what must stay fixed: hue carries the identity of the input, chroma decides how far that identity reaches into each layer, and lightness keeps the surfaces in order.
Those three controls only exist if the color format hands them to you, and the
ones we reach for by default do not. Rotate the hue of an hsl() value and its
perceived brightness moves too, dragging the hierarchy with it.
This is where OKLCH comes into play. So what is it? A CSS color space with one
axis per question: how light (L), how colorful (C), and which color (H).
Those axes are tuned to human perception and, more useful here, they are
independent — one can move while the other two hold still.
Start with coordinates, not a swatch
Hex is a fine delivery format and a poor editing model. Its channels describe display output, not the qualities a designer is trying to control. OKLCH, on the other hand, exposes three coordinates that map cleanly to the job:
| Channel | Controls | Practical range |
|---|---|---|
L | Perceived lightness | 0–1 |
C | Colorfulness | 0–roughly 0.4 |
H | Hue angle | 0–360° |
A gamut is the range of colors a device or color space can reproduce. In the simplified map below, the outer shape represents the visible color space while the triangle marks sRGB, the baseline gamut for the web.
OKLCH can describe colors both inside and outside that boundary, and falling outside sRGB does not mean the color is impossible to show. Wide-gamut displays reach well past that triangle and render those colors properly. They are the exception, not the baseline.
Every other screen maps the value to the nearest color it can produce, and you do not get to choose the substitute. Staying inside sRGB is the widest-compatibility path: the value you write is the value that renders, on every display. The rest of this article treats that boundary as the one to respect.
The converter below accepts common CSS color formats, normalizes them into OKLCH, and shows the sRGB ceiling for the current lightness and hue. Move one channel at a time and the model becomes much easier to reason about.
Equal numbers should produce equal weight
HSL calls one of its channels “lightness,” but the number is not perceptually
uniform. A yellow and a blue at 60% do not look equally bright. If those colors
become two states in the same component, one carries much more visual weight than
the other.
OKLCH lightness is designed around perception. Rotate the hue while holding L
and C, and the swatches stay far closer in apparent brightness. Compare that
with the HSL row:
Drag the lightness slider toward either end and watch the four swatches stop keeping pace with each other. The sRGB boundary narrows as colors approach black or white, so a chroma that was reachable in the middle of the range no longer is.
The demo hands those out-of-range values to CSS untouched, which leaves the browser to pick the replacement. That is the part to avoid in production: two different values can collapse onto the same rendered color, and a hierarchy you calibrated quietly loses a step. Reduce chroma yourself before the value becomes a token, and the color you emit is the color that renders.
This is the first property a runtime theme needs. A surface role can keep a stable brightness even while its hue changes completely.
A ramp should keep its identity
Palettes can also lose consistency across their light-to-dark steps. Start with one saturated color, generate lighter and darker steps in HSL, and the ramp often changes character on the way to its ends. The numeric hue stays still while the perceived hue drifts.
With OKLCH, lightness can move without asking hue to compensate. The ramp remains recognizably one color from shadow to highlight.
Each step across a row below is a different lightness, fixed per swatch — that fixed progression is what makes it a ramp rather than a set of related colors. The slider moves the other value, hue, which is exactly the channel a runtime theme hands over to its input. Sweep it and watch which row keeps its identity.
This matters beyond aesthetics. A stable hue makes semantic tokens easier to
understand: --accent-subtle, --accent-border, and --accent-solid read as one
family instead of three colors that happen to be related.
The wheel has an irregular edge
OKLCH is usually drawn as a cylinder: lightness runs up the axis, chroma moves outward from it, and hue rotates around it. Stack a hue wheel at every lightness step and you have the whole space:
- L
- up the axis, from 0.17 at the bottom to 0.95 at the top
- C
- outward, from the gray center to the rim
- H
- around each slice
Each slice is a wheel. Gray sits at the center, where chroma is zero, and every rim color is the most colorful version of that hue sRGB can still show. The model is simple. The display gamut is not.
At a given lightness, some hues can reach much more chroma than others before they leave sRGB. The wheel below is a single slice, generated from actual OKLCH samples. Change lightness and chroma to see the available ring expand, contract, and clip.
oklch(0.65 0.16 255)The chroma readout carries the whole point: it shows the value you asked for next to the ceiling for the hue you are currently on. Hold lightness still and rotate the handle around the rim. Your value never moves, the ceiling does, and the ring clips wherever the two cross. That is the irregular edge — not a property of OKLCH, but the shape of sRGB seen from inside it.
Unrolled, the edge is a curve. The solid line is the ceiling at each hue; the dashed line is what you asked for. Everywhere the dashed line sits above the curve, the color you wrote is not the color that renders:
Maximum sRGB chroma at every hue, lightness held still
Maximum sRGB chroma for every hue at lightness 0.65. The ceiling peaks at 0.309 near hue 322 degrees and bottoms out at 0.110 near hue 200 degrees. At chroma 0.160, 43 percent of hues fall outside sRGB.
At this lightness the ceiling runs from 0.110 to 0.309, so one flat chroma of 0.160 leaves sRGB on 43% of the wheel.
Which is why one raw chroma value cannot be reused across every hue. C: 0.22 may
be valid for a purple and impossible for a cyan at the same lightness. A robust
system works with a percentage of each hue's available chroma, then clamps the
result to the target gamut.
Keep hierarchy fixed and let identity move
The theme algorithm can now be small:
- Assign a fixed lightness to each semantic surface.
- Take hue from the runtime color.
- Give each surface a chroma budget.
- Clamp that chroma to the sRGB maximum for the surface's lightness and hue.
The result is a family of tinted surfaces whose ordering does not change when the runtime color changes.
Every surface keeps its lightness. Only the hue and a sliver of chroma come from the picked color, so contrast never breaks.
Accent action--background: oklch(0.97 lightness, 0.001 255)oklch(0.17 lightness, 0.004 255)--card: oklch(0.93 lightness, 0.003 255)oklch(0.21 lightness, 0.006 255)--foreground: oklch(0.17 lightness, 0.003 255)oklch(0.96 lightness, 0.001 255)--accent: oklch(0.62 lightness, 0.147 255)oklch(0.62 lightness, 0.147 255)In code, each surface is a constrained projection of the input rather than a mix with a hardcoded neutral:
function createSurface({ lightness, hue, tint, budget }: SurfaceInput) {const ceiling = getMaximumChroma(lightness, hue)const chroma = Math.min(tint * budget * ceiling, ceiling)return `oklch(${lightness} ${chroma} ${hue})`}
The fixed lightness is the hierarchy. hue is the identity. tint is the
system-wide intensity control, and budget decides how much color belongs in one
specific role. Backgrounds can stay nearly neutral while borders and muted text
carry more of the hue.
Surfaces are deliberately restrained. The accent is the opposite case, and it needs its own rule: a button or a link should look like the color the user actually picked, not like a wall tinted with it. Keep its lightness inside a narrow band that stays readable against both themes, and let it spend far more of its chroma budget than any surface does.
Gamut mapping is part of the algorithm
Browsers can parse an OKLCH value that an sRGB display cannot reproduce. They will map or clip it into something displayable, but that fallback is not a theming strategy. Several intended colors may collapse into the same output, and the result can shift enough to disturb the hierarchy you calibrated.
Clamp before you emit the token. The converter preview and generated theme use
Culori's clampChroma() to reduce chroma while preserving lightness and hue:
const safeColor = clampChroma({ mode: 'oklch', l: lightness, c: chroma, h: hue },'oklch','rgb')
If the product supports Display P3, generate that as a progressive enhancement. Keep an sRGB-safe value as the baseline instead of assuming every screen can show the wider color.
Predictable is not the same as verified
Holding OKLCH lightness steady makes brightness relationships far more stable than
doing the same work in HSL. It does not turn L into an accessibility score.
Chroma, hue, font size, weight, and the final rendered background still affect the
result.
Treat the lightness values as design-system invariants, then measure the actual foreground and background pairs across the full hue sweep. Use WCAG contrast for conformance and APCA as a perceptual check. If one pair fails, adjust lightness; changing hue only hides the problem in one theme and moves it into another.
That gives runtime color a safe boundary. The input gets to shape the interface, but it never gets to redefine which surface is behind, which text is foreground, or which action deserves attention.
Theming de color dinámico con OKLCH
La mayoría de los design systems arrancan con una paleta que alguien armó a mano. Ese supuesto deja de servir cuando el color llega en runtime: la marca de un workspace, un color sacado de una imagen, la preferencia de un equipo o un valor que devuelve una API.
En ese punto el color ya no es un token. Es input que no podés controlar.
Meter ese input en --primary es fácil. Lo difícil de verdad es dejar que influya
en los fondos de página, las cards, los bordes, el texto y los estados de foco sin
aplastar la jerarquía visual. No podés permitir que un azul oscuro y un amarillo
brillante arrastren cada superficie a su propio brillo.
La jugada útil es separar lo que puede cambiar de lo que tiene que quedar fijo: el hue lleva la identidad del input, el chroma decide hasta dónde llega esa identidad en cada capa, y el lightness mantiene las superficies en orden.
Esos tres controles solo existen si el formato de color te los da, y los que usamos
por defecto no lo hacen. Rotá el hue de un valor hsl() y su brillo percibido
también se mueve, y se lleva puesta la jerarquía.
Acá es donde entra OKLCH. ¿Y qué es? Un espacio de color de CSS con un eje por
pregunta: qué tan claro (L), qué tan colorido (C) y qué color (H). Esos ejes
están calibrados para la percepción humana y, lo que más importa acá, son
independientes: uno se puede mover mientras los otros dos se quedan quietos.
Arrancá por las coordenadas, no por una muestra
Hex está bien como formato de entrega y es malo como modelo de edición. Sus canales describen la salida de la pantalla, no las cualidades que un diseñador quiere controlar. OKLCH, en cambio, expone tres coordenadas que encajan justo con el trabajo:
| Canal | Controla | Rango práctico |
|---|---|---|
L | Lightness percibido | 0–1 |
C | Colorido | 0–más o menos 0.4 |
H | Ángulo de hue | 0–360° |
Un gamut es el rango de colores que un dispositivo o un espacio de color puede reproducir. En el mapa simplificado de abajo, la forma exterior representa el espacio de colores visibles y el triángulo marca sRGB, el gamut base de la web.
OKLCH puede describir colores tanto adentro como afuera de ese límite, y quedar fuera de sRGB no significa que el color sea imposible de mostrar. Las pantallas de gamut amplio llegan bastante más allá de ese triángulo y muestran esos colores como corresponde. Son la excepción, no la base.
Cualquier otra pantalla lleva el valor al color más cercano que puede producir, y el reemplazo no lo elegís vos. Quedarte dentro de sRGB es el camino con mayor compatibilidad: el valor que escribís es el valor que se ve, en cualquier pantalla. El resto del artículo toma ese límite como el que hay que respetar.
El conversor de abajo acepta los formatos de color CSS más comunes, los normaliza a OKLCH y te muestra el techo de sRGB para el lightness y el hue actuales. Mové un canal por vez y el modelo se vuelve mucho más fácil de razonar.
Números iguales deberían dar el mismo peso
HSL le dice "lightness" a uno de sus canales, pero ese número no es
perceptualmente uniforme. Un amarillo y un azul al 60% no se ven igual de
brillantes. Si esos colores pasan a ser dos estados del mismo componente, uno carga
mucho más peso visual que el otro.
El lightness de OKLCH está pensado alrededor de la percepción. Rotá el hue
manteniendo L y C, y las muestras quedan mucho más parejas en brillo aparente.
Comparalo con la fila de HSL:
Llevá el slider de lightness hacia cualquiera de las puntas y vas a ver cómo las cuatro muestras dejan de ir a la par. El límite de sRGB se angosta a medida que los colores se acercan al negro o al blanco, así que un chroma que se alcanzaba en el medio del rango ya no se alcanza.
La demo le pasa esos valores fuera de rango a CSS tal cual, y deja que el navegador elija el reemplazo. Eso es justo lo que hay que evitar en producción: dos valores distintos pueden terminar en el mismo color renderizado, y una jerarquía que calibraste pierde un escalón sin hacer ruido. Bajá el chroma vos antes de que el valor se convierta en token, y el color que emitís es el color que se ve.
Esta es la primera propiedad que necesita un tema en runtime. Un rol de superficie puede mantener un brillo estable aunque su hue cambie por completo.
Una rampa tiene que conservar su identidad
Las paletas también pueden perder consistencia a lo largo de sus pasos de claro a oscuro. Arrancá con un color saturado, generá pasos más claros y más oscuros en HSL, y la rampa muchas veces cambia de carácter camino a las puntas. El hue numérico se queda quieto mientras el hue percibido se corre.
Con OKLCH, el lightness se puede mover sin pedirle al hue que compense. La rampa sigue siendo claramente un mismo color de la sombra a la luz.
Cada paso de una fila de abajo tiene un lightness distinto, fijo por muestra: esa progresión fija es lo que la convierte en rampa y no en un grupo de colores parecidos. El slider mueve el otro valor, el hue, que es justo el canal que un tema en runtime le entrega a su input. Barrelo y fijate qué fila conserva su identidad.
Esto importa más allá de lo estético. Un hue estable hace que los tokens semánticos
sean más fáciles de entender: --accent-subtle, --accent-border y
--accent-solid se leen como una familia y no como tres colores que casualmente se
parecen.
La rueda tiene un borde irregular
OKLCH se suele dibujar como un cilindro: el lightness sube por el eje, el chroma se aleja de él hacia afuera y el hue gira alrededor. Apilá una rueda de hue en cada paso de lightness y tenés el espacio entero:
- L
- sube por el eje, de 0.17 abajo a 0.95 arriba
- C
- hacia afuera, del centro gris al borde
- H
- alrededor de cada capa
Cada capa es una rueda. El gris está en el centro, donde el chroma es cero, y cada color del borde es la versión más colorida de ese hue que sRGB todavía puede mostrar. El modelo es simple. El gamut de la pantalla, no.
Con un lightness dado, algunos hues llegan a mucho más chroma que otros antes de salirse de sRGB. La rueda de abajo es una sola capa, generada a partir de muestras reales de OKLCH. Cambiá el lightness y el chroma para ver cómo el anillo disponible se expande, se achica y se recorta.
oklch(0.65 0.16 255)La lectura del chroma resume todo: muestra el valor que pediste al lado del techo para el hue en el que estás parado. Dejá quieto el lightness y girá el control alrededor del borde. Tu valor nunca se mueve, el techo sí, y el anillo se recorta donde los dos se cruzan. Ese es el borde irregular: no es una propiedad de OKLCH, es la forma de sRGB vista desde adentro.
Desenrollado, el borde es una curva. La línea sólida es el techo en cada hue; la punteada es lo que pediste. En todos los lugares donde la punteada queda por encima de la curva, el color que escribiste no es el color que se ve:
Chroma máximo en sRGB para cada hue, con el lightness quieto
Chroma máximo en sRGB para cada hue con lightness 0.65. El techo llega a 0.309 cerca del hue 322 grados y toca fondo en 0.110 cerca del hue 200 grados. Con chroma 0.160, el 43 por ciento de los hues queda fuera de sRGB.
Con este lightness el techo va de 0.110 a 0.309, así que un chroma fijo de 0.160 se sale de sRGB en el 43% de la rueda.
Por eso no podés reusar un único valor crudo de chroma para todos los hues.
C: 0.22 puede ser válido para un violeta e imposible para un cian con el mismo
lightness. Un sistema robusto trabaja con un porcentaje del chroma disponible de
cada hue y después ajusta el resultado al gamut de destino.
Dejá fija la jerarquía y que la identidad se mueva
Ahora el algoritmo del tema puede ser chico:
- Asigná un lightness fijo a cada superficie semántica.
- Tomá el hue del color en runtime.
- Dale a cada superficie un presupuesto de chroma.
- Ajustá ese chroma al máximo de sRGB para el lightness y el hue de la superficie.
El resultado es una familia de superficies teñidas cuyo orden no cambia cuando cambia el color en runtime.
Cada superficie mantiene su lightness. Del color elegido solo viajan el hue y una pizca de chroma, así que el contraste nunca se rompe.
Acción con accent--background: oklch(0.97 lightness, 0.001 255)oklch(0.17 lightness, 0.004 255)--card: oklch(0.93 lightness, 0.003 255)oklch(0.21 lightness, 0.006 255)--foreground: oklch(0.17 lightness, 0.003 255)oklch(0.96 lightness, 0.001 255)--accent: oklch(0.62 lightness, 0.147 255)oklch(0.62 lightness, 0.147 255)En código, cada superficie es una proyección acotada del input y no una mezcla con un neutro hardcodeado:
function createSurface({ lightness, hue, tint, budget }: SurfaceInput) {const ceiling = getMaximumChroma(lightness, hue)const chroma = Math.min(tint * budget * ceiling, ceiling)return `oklch(${lightness} ${chroma} ${hue})`}
El lightness fijo es la jerarquía. hue es la identidad. tint es el control de
intensidad de todo el sistema, y budget decide cuánto color le corresponde a un
rol específico. Los fondos pueden quedar casi neutros mientras los bordes y el
texto atenuado cargan más del hue.
Las superficies son austeras a propósito. El acento es el caso opuesto, y necesita su propia regla: un botón o un link tiene que verse como el color que el usuario eligió de verdad, no como una pared teñida con él. Mantené su lightness dentro de una banda angosta que siga siendo legible en los dos temas, y dejalo gastar mucho más de su presupuesto de chroma que cualquier superficie.
El gamut mapping es parte del algoritmo
Los navegadores pueden interpretar un valor OKLCH que una pantalla sRGB no puede reproducir. Lo van a mapear o recortar a algo que se pueda mostrar, pero ese fallback no es una estrategia de theming. Varios colores pensados como distintos pueden terminar en la misma salida, y el resultado se puede correr lo suficiente como para romper la jerarquía que calibraste.
Ajustá antes de emitir el token. La vista previa del conversor y el tema generado
usan clampChroma() de Culori para bajar el chroma conservando el lightness y el
hue:
const safeColor = clampChroma({ mode: 'oklch', l: lightness, c: chroma, h: hue },'oklch','rgb')
Si el producto soporta Display P3, generalo como mejora progresiva. Mantené un valor seguro en sRGB como base en lugar de asumir que todas las pantallas pueden mostrar el color más amplio.
Predecible no es lo mismo que verificado
Mantener fijo el lightness de OKLCH hace que las relaciones de brillo sean mucho más
estables que si hicieras lo mismo en HSL. Pero no convierte L en un puntaje de
accesibilidad. El chroma, el hue, el tamaño de fuente, el peso y el fondo final
renderizado siguen afectando el resultado.
Tratá los valores de lightness como invariantes del design system, y después medí los pares reales de primer plano y fondo en todo el barrido de hue. Usá el contraste de WCAG para el cumplimiento y APCA como chequeo perceptual. Si un par falla, ajustá el lightness; cambiar el hue solo esconde el problema en un tema y lo pasa a otro.
Eso le da al color en runtime un límite seguro. El input puede darle forma a la interfaz, pero nunca puede redefinir qué superficie va atrás, qué texto va adelante o qué acción merece atención.