wandres.dev
COLOR II · OKLCH y los espacios perceptuales

La sintaxis de oklch() y oklab() en CSS

La gramática exacta de las dos funciones, qué significan los porcentajes en cada canal, para qué sirve la palabra clave none, y cómo guardar canales sueltos en custom properties.

⏱ 16 min

La sintaxis de CSS Color 4 rompió con dos costumbres que llevaban veinte años: se acabaron las comas entre componentes y se acabó el rgba() aparte para la opacidad. Las funciones nuevas siguen todas el mismo patrón, así que aprender oklch() es aprender también lab(), color() y las cinco que quedan. Y trae una palabra clave, none, que parece un detalle y resulta ser la pieza que hace que las mezclas de tono se comporten.

🎯 Al terminar esta lección sabrás
  • Escribir oklch() y oklab() con la gramática correcta y su canal alfa.
  • Traducir porcentajes a números en cada uno de los canales.
  • Usar none y explicar qué hace en interpolación.
  • Guardar canales sueltos en custom properties y componer colores con ellos.

La forma canónica

.a { color: oklch(62% 0.18 264); }
.b { color: oklch(62% 0.18 264 / 0.5); }
.c { color: oklab(62% -0.02 -0.17); }
.d { color: oklab(62% -0.02 -0.17 / 50%); }

Tres componentes separados por espacios, y el alfa detrás de una barra. Sin comas en ninguna parte. La barra no es un divisor: es un separador sintáctico que la especificación eligió para que el alfa quede visualmente aparte de las coordenadas del color, que es una cosa distinta.

oklab() toma las coordenadas cartesianas: claridad, eje verde-rojo y eje azul-amarillo. oklch() toma las polares: claridad, croma y ángulo de tono. Describen exactamente el mismo espacio y se convierten una en otra sin pérdida. Para escribir a mano querrás casi siempre oklch(), porque sus tres números corresponden a tres decisiones que sabes tomar. oklab() aparece sobre todo como espacio de interpolación, donde las coordenadas cartesianas evitan el problema de tener que decidir por qué lado se gira el tono.

Rangos y unidades de cada canal

Cada canal acepta un número o un porcentaje, y la equivalencia entre los dos no es la misma en todos.

Canal Número Porcentaje Nota
L de oklch y oklab 0 a 1 0% a 100% 100% equivale a 1
C de oklch 0 en adelante 0% a 100% 100% equivale a 0.4
H de oklch grados no aplica acepta deg, rad, grad, turn
a y b de oklab negativos y positivos -100% a 100% 100% equivale a 0.4
alfa 0 a 1 0% a 100% 100% equivale a 1

El porcentaje del croma es la trampa clásica. oklch(60% 50% 264) no es “medio croma de lo que quepa”: es croma 0.2, un valor absoluto que en muchos tonos está fuera del gamut de sRGB. Escribe el croma como número decimal y te ahorras el problema entero.

El tono acepta cualquier unidad angular, y los valores fuera de 0-360 se normalizan dando vueltas. oklch(62% 0.18 624) es lo mismo que oklch(62% 0.18 264). Esto importa cuando calculas el ángulo con calc() y no quieres andar con módulos.

:root { --h: 264; }
.hermano-1 { color: oklch(62% 0.18 calc(var(--h) + 60)); }
.hermano-2 { color: oklch(62% 0.18 calc(var(--h) + 120)); }

Los valores fuera de rango en los otros canales se recortan al analizar: una L negativa vale 0, una L de 1.5 vale 1, un croma negativo vale 0. No se descarta la declaración.

La palabra clave none

Cualquiera de los cuatro canales acepta none. Significa que ese componente está ausente, que no es lo mismo que valer cero, aunque a la hora de pintar se comporte como cero.

.gris    { color: oklch(60% 0 none); }    /* el tono no significa nada aqui */
.apagado { color: oklch(60% none 264); }  /* croma ausente, tono declarado */

La diferencia aparece en la interpolación. Cuando dos colores se mezclan —en un degradado, en una transición, en color-mix()— la especificación primero los lleva a un espacio común, y ahí aplica una regla: si un componente está ausente en uno de los dos colores y presente en el otro, el ausente toma el valor del otro antes de interpolar. Es el mecanismo de carry forward.

Eso resuelve un problema muy concreto. Un gris no tiene tono; su tono es un número arbitrario que sale de la conversión. Si mezclas ese gris con un azul y el tono arbitrario resulta ser 90, la mezcla pasa por un verde que no habías pedido. Declarando el gris como oklch(60% 0 none) le dices al motor que ese ángulo no significa nada, con lo que la interpolación adopta el tono del azul y la mezcla es una desaturación limpia.

/* Un gris que se mezcla bien con cualquier cosa. */
:root { --neutro: oklch(60% 0 none); }

Fuera de la interpolación, none se comporta como cero, así que la declaración es segura en cualquier sitio.

⚠️
none no se propaga entre espacios distintos

El carry forward solo funciona entre componentes análogos. Si mezclas en oklab un color que tenía el tono ausente en oklch, la conversión a coordenadas cartesianas resuelve el ausente a cero y ya no hay nada que arrastrar. Declara el none en el mismo espacio en el que vayas a interpolar, o no cuentes con él.

Canales sueltos en custom properties

La sustitución de var() ocurre a nivel de tokens, antes de que nadie compruebe la gramática de la propiedad. Eso significa que puedes guardar partes de un color y componerlas después, y es la base de casi todos los sistemas de tokens modernos.

:root {
  --marca-h: 264;
  --marca-c: 0.18;

  --marca:        oklch(62% var(--marca-c) var(--marca-h));
  --marca-suave:  oklch(94% 0.028          var(--marca-h));
  --marca-fuerte: oklch(46% 0.175          var(--marca-h));
}

.aviso {
  /* Cambiar el tema entero es cambiar un numero. */
  --marca-h: 29;
}

El patrón tiene una virtud que no es evidente: cambia el tono de todo un subárbol con una sola declaración heredada, sin tocar ninguna de las reglas que consumen los tokens. Y tiene un límite que sí conviene conocer: los canales guardados así son texto sin tipo, con lo que no se pueden animar. El motor no sabe que --marca-h es un número, solo que es un flujo de tokens.

La solución es registrarlos:

@property --marca-h {
  syntax: "<number>";
  inherits: true;
  initial-value: 264;
}

.rotando {
  transition: --marca-h 400ms linear;
  &:hover { --marca-h: 324; }
}

Con el registro, el navegador interpola el número y el color se recalcula en cada fotograma. Sin él, el cambio es un salto seco.

Registrar el color entero no es lo mismo que registrar sus canales

Tienes dos formas de animar un color con @property y no son intercambiables. Puedes registrar una propiedad con syntax: "<color>" y darle dos colores; entonces el navegador interpola en el espacio de interpolación por defecto para valores de color, que la especificación fija en Oklab, y el camino es una recta en coordenadas cartesianas. O puedes registrar los canales por separado como <number> y <angle> y dejar que el color se recomponga en cada fotograma; entonces cada canal recorre su propia recta y el camino resultante en el espacio de color es una curva, porque el tono es un ángulo. Los dos caminos empiezan y acaban en el mismo sitio y en medio pasan por colores distintos: el primero atraviesa la zona apagada del centro del espacio, el segundo bordea manteniendo el croma. Para una animación de tono, casi siempre quieres el segundo, y ahí la elección importa además por otra razón: al interpolar el ángulo tú controlas si vas de 350 a 10 por el camino corto o dando la vuelta larga, simplemente escribiendo 370 en lugar de 10 en el valor final. Con syntax: "<color>" esa decisión la toma la especificación por ti y siempre es el camino corto. Es el mismo tipo de control que aparece en los degradados con las palabras longer hue y shorter hue, pero aquí lo consigues sin sintaxis nueva, solo eligiendo qué registras.

⚔️ Compón colores por canales
  1. Escribe un token de marca partido en tres custom properties y deriva de él tres variantes de claridad.
  2. Comprueba que oklch(60% 50% 264) y oklch(60% 0.2 264) son el mismo color.
  3. Declara un gris con none en el tono y mézclalo con un color saturado. Repite sin none y compara.
  4. Registra el tono con @property y anímalo en un :hover. Prueba a terminar en 370 en vez de en 10.