Sintaxis de color relativo: la palabra from
Cómo descomponer un color en sus canales, qué escala tiene cada canal en cada función, por qué hace falta calc(), y qué desbloquea poder leer un color que no conoces.
Durante veinticinco años CSS pudo escribir colores pero no leerlos. Un valor de color era opaco: si te llegaba un #3364db por una custom property, lo único que podías hacer era usarlo tal cual. La sintaxis de color relativo rompe eso con una palabra clave de cuatro letras, y a partir de ahí un color deja de ser un dato y pasa a ser un punto del que puedes moverte en la dirección que quieras.
- Escribir cualquier función de color en su forma relativa con
from. - Recordar la escala de cada canal en cada función y por qué importa.
- Convertir un color de un espacio a otro usando la propia sintaxis.
- Detectar los tres errores que se cometen siempre al empezar.
La gramática
Cualquier función de color acepta un color de origen precedido de from. A partir de ahí, dentro de esa función, los nombres de sus canales están disponibles como valores:
:root { --marca: #3364db; }
.a { color: oklch(from var(--marca) l c h); } /* el mismo color */
.b { color: oklch(from var(--marca) 0.85 c h); } /* mas claro */
.c { color: oklch(from var(--marca) l 0 h); } /* desaturado */
.d { color: oklch(from var(--marca) l c calc(h + 180)); } /* complementario */
.e { color: oklch(from var(--marca) l c h / 0.4); } /* traslucido */
El color de origen puede venir en cualquier formato: un hex, un nombre, una custom property, otra función de color, incluso currentColor. El motor lo convierte al espacio de la función que estás escribiendo y te entrega sus canales.
Los nombres de canal disponibles son los de la función destino, no los del origen. Dentro de oklch(from ...) tienes l, c, h y alpha. Dentro de rgb(from ...) tienes r, g, b y alpha. Mezclarlos no compila:
/* Invalido: r no existe dentro de oklch. */
.mal { color: oklch(from red r c h); }
Cada canal que no menciones hay que escribirlo igualmente: la función sigue necesitando sus tres argumentos. Por eso oklch(from var(--x) l c h) aparece tanto: es la forma de decir “este color, tal cual, pero voy a tocar el alfa”.
Los canales son números y hay que saber su escala
Este es el punto donde todo el mundo se estrella la primera vez. Las palabras de canal se sustituyen por números sin unidad, y la escala de cada uno depende de la función:
| Función | Canales | Escala |
|---|---|---|
rgb() |
r, g, b |
0 a 255 |
hsl() |
h, s, l |
grados, 0 a 100, 0 a 100 |
hwb() |
h, w, b |
grados, 0 a 100, 0 a 100 |
lab() |
l, a, b |
0 a 100, sin acotar |
lch() |
l, c, h |
0 a 100, sin acotar, grados |
oklab() |
l, a, b |
0 a 1, sin acotar |
oklch() |
l, c, h |
0 a 1, sin acotar, grados |
color() |
r, g, b |
0 a 1 |
| todas | alpha |
0 a 1 |
La l de oklch() va de 0 a 1 y la de lch() de 0 a 100. Ese es el origen de la mitad de los colores negros inesperados: alguien escribe oklch(from var(--x) calc(l - 10) c h) pensando en puntos porcentuales y se lleva por delante toda la claridad.
Como son números y no valores tipados, cualquier operación necesita calc() y las unidades no se pueden mezclar:
/* Correcto. */
.a { color: oklch(from var(--m) calc(l * 0.9) c h); }
.b { color: oklch(from var(--m) calc(l - 0.08) c h); }
.c { color: oklch(from var(--m) l calc(c * 1.2) calc(h + 30)); }
/* Incorrecto: l es un numero, no un porcentaje. */
.mal { color: oklch(from var(--m) calc(l - 10%) c h); }
Sí puedes usar los canales en clamp(), min() y max(), cosa que resulta muy útil para poner suelos y techos:
/* Aclara, pero nunca por encima de 0.95 ni por debajo de 0.05. */
.seguro { color: oklch(from var(--m) clamp(0.05, calc(l + 0.15), 0.95) c h); }
Cambiar de espacio por el camino
El origen se convierte al espacio de la función destino, así que la propia sintaxis es un conversor:
/* Lee un hex y devuelve sus coordenadas OKLCH intactas. */
.igual { color: oklch(from #3364db l c h); }
/* Lee un OKLCH y lo escribe en Display P3. */
.p3 { color: color(from oklch(70% 0.2 150) display-p3 r g b); }
Eso permite un patrón muy práctico: operar en el espacio que le conviene a la operación, sin importar en cuál esté escrito el token. Aclarar y oscurecer se hacen bien en oklch aunque tu token esté en hex. Ajustar el canal alfa da igual dónde. Y si necesitas el equivalente en sRGB de algo definido en un espacio ancho, color(from ... srgb r g b) te lo da.
Un detalle importante y poco documentado: los canales no se recortan al sustituirse. Si el color de origen cae fuera del gamut de la función destino, los números que obtienes pueden estar fuera de rango —un r negativo, un g por encima de 255— y las operaciones se hacen sobre esos valores. El recorte, si llega, ocurre después, al pintar. Eso es lo correcto: recortar antes de operar destruiría información que quizá ibas a devolver al rango tú mismo.
Los tres errores de siempre
Olvidar calc(). oklch(from var(--m) l - 0.1 c h) no es una resta, son cuatro tokens sueltos y la declaración se descarta. Todo lo que no sea el nombre pelado del canal va dentro de calc().
Confundir la escala. Ya está dicho, y aun así ocurre. Si vas a tocar l en oklch(), los incrementos útiles están entre 0.02 y 0.15.
Suponer que el resultado está en el gamut. Subir el croma de un color que ya estaba cerca del borde produce un color que la pantalla no puede mostrar, y lo que veas dependerá de cómo lo mapee el navegador. Si derivas hacia arriba en croma, pon un techo con min().
Puedes escribir oklch(from var(--m) l none h) para producir un color con el croma ausente. Se pinta como si fuera cero, es decir un gris de la misma claridad, pero al interpolarlo con otro color el croma se toma del otro. Es la forma limpia de decir “el gris equivalente a este color” cuando ese gris va a acabar en un degradado o en una transición.
La sintaxis relativa cambia la naturaleza de un sistema de color: los valores dejan de ser independientes y pasan a formar un grafo de dependencias, con un token raíz y todo lo demás colgando de él. La ventaja es evidente y es la razón por la que merece la pena: cambias el color de marca y los treinta valores derivados se recolocan solos, coherentes, sin que nadie los haya revisado. Lo que casi nunca se dice es que el error también se propaga, y se amplifica. Si el token raíz está cerca del borde del gamut, la variante :hover que sube el croma un 20% cae fuera, el navegador la mapea de vuelta, y acabas con un :hover que se ve idéntico al estado normal. No hay error, no hay aviso, simplemente el botón no reacciona y nadie sabe por qué. Lo mismo con la claridad: derivar calc(l - 0.08) desde un token que ya está en l igual a 0.12 produce 0.04, que sobre negro es indistinguible del original. La disciplina que evita esto tiene dos partes. La primera es acotar siempre las derivaciones que se alejan del centro, con clamp() en la claridad y con min() en el croma; cuesta seis caracteres y convierte un fallo silencioso en un comportamiento degradado pero visible. La segunda, más profunda, es derivar hacia una referencia en vez de por un delta fijo: en lugar de restar 0.08 a la claridad, mezcla un 12% hacia el color de la superficie. Restar un delta te saca del rango en los extremos; acercarte a un punto de referencia nunca lo hace, porque el destino siempre está dentro. Es la diferencia entre una traslación, que puede salirse, y una interpolación, que por construcción no puede. Por eso los sistemas de color que aguantan bien el cambio de tema están escritos casi siempre con mezclas hacia Canvas y CanvasText, y no con sumas y restas sobre la claridad.
- Escribe un color de marca en hex y produce con
fromsu versión enoklch, su complementario y su gris equivalente. - Comprueba qué pasa si restas 10 en vez de 0.1 a la
lde unoklch(). - Acota una derivación de croma con
min()y verifica que el:hoversigue distinguiéndose para un token muy saturado. - Convierte un color de
oklchacolor(display-p3 ...)con la forma relativa y compara los tres números.