wandres.dev
COLOR III · color-mix, colores relativos, gamut

Mapeo de gamut: qué pasa cuando pides un color imposible

La diferencia medida entre recortar por canal y reducir croma, el algoritmo que especifica CSS Color 4, y por qué el valor calculado sigue siendo el que pediste.

⏱ 19 min

oklch() describe colores que existen para el sistema visual humano, y el conjunto de colores que tu pantalla puede producir es mucho más pequeño. Cuando pides uno de los otros no hay error ni aviso: el navegador elige por ti un sustituto, y la calidad de esa elección va desde razonable hasta desastrosa según qué algoritmo use. Vale la pena saber qué está pasando, porque explica por qué a veces dos colores distintos se ven exactamente iguales.

🎯 Al terminar esta lección sabrás
  • Explicar qué es el gamut de destino y cuándo un color queda fuera.
  • Medir qué distorsiona el recorte por canal en claridad y en tono.
  • Seguir el algoritmo de mapeo que especifica CSS Color 4.
  • Diseñar sabiendo que el mapeo existe, en vez de pelearse con él.

El color que no existe en tu pantalla

Una pantalla produce color encendiendo tres primarios en distintas proporciones. El conjunto de colores alcanzables es el sólido generado por todas las combinaciones de esos tres, y cualquier cosa fuera de ese sólido es literalmente imposible: no hay ninguna manera de encender los subpíxeles para conseguirla.

En coordenadas de sRGB lineal, estar dentro del gamut significa que los tres canales caen entre 0 y 1. oklch(55% 0.2 195) produce un canal rojo negativo, que es lo mismo que decir “haría falta restar luz roja”, cosa que un panel no sabe hacer.

El navegador tiene entonces que elegir un color que sí exista. Hay dos familias de respuestas y la diferencia entre ellas se mide.

Recortar por canal y lo que rompe

La respuesta barata es recortar cada canal a su rango: lo que sea menor que 0 pasa a 0, lo que sea mayor que 1 pasa a 1. Es una línea de código y es lo que han hecho los navegadores durante décadas.

El problema es que recortar canales de forma independiente mueve el color en las tres dimensiones a la vez. Estos son casos medidos:

Pedido Recorte por canal Claridad resultante Tono resultante
oklch(55% 0.2 195) #009095 59.5% 199.1
oklch(70% 0.25 150) #00c537 71.6% 145.1
oklch(62% 0.28 264) #276dff 58.3% 262.4
oklch(65% 0.3 29) #ff0000 62.8% 29.2

El primero pedía claridad 55 y devuelve 59.5, con el tono desplazado cuatro grados. En una escala donde has repartido la claridad con cuidado, ese color se sale de la rampa y se nota. El segundo se aclara un punto y medio y se desvía cinco grados de tono.

El cuarto caso es el que mejor ilustra lo que se pierde. oklch(65% 0.3 29) se recorta exactamente a #ff0000, que es el rojo primario. Y no es el único: cualquier color cuyo canal rojo pase de 1 y cuyos otros dos canales queden por debajo de 0 acaba en ese mismo punto. Es decir, el recorte manda familias enteras de colores distintos al mismo sitio. Si construyes una escala de rojos vivos con cinco pasos y tres de ellos están fuera del gamut por ese lado, esos tres serán el mismo color en pantalla y tú los verás distintos en tu portátil P3.

Reducir croma: lo que especifica CSS Color 4

La alternativa es mantener fijas las dos coordenadas que llevan la información estructural —claridad y tono— y sacrificar la que solo lleva intensidad. Bajas el croma hasta que el color entra en el gamut.

Con esa estrategia, los mismos cuatro casos quedan así:

Pedido Croma reducido Claridad Tono
oklch(55% 0.2 195) #008282 55.0% 195.0
oklch(70% 0.25 150) #00be58 70.0% 150.0
oklch(62% 0.28 264) #447cff 62.0% 264.0
oklch(65% 0.3 29) #ff362a 65.0% 29.0

Claridad y tono exactos en los cuatro. El color pierde intensidad, que es lo único que se puede perder, y conserva su sitio en la rampa y su familia.

El algoritmo de la especificación refina esa idea con un detalle: una búsqueda binaria pura sobre el croma llega justo al borde del gamut, y el borde es un sitio incómodo porque las diferencias de un paso de croma ahí son imperceptibles. Así que se permite un recorte pequeño al final, siempre que la distancia entre el candidato y su recorte esté por debajo del umbral de percepción, fijado en 0.02 de delta E en Oklab. Eso se llama MINDE, mínimo delta E, y hace que el resultado quede lo más cerca posible del borde sin pasarse.

flowchart TB
A[Color pedido convertido a oklch] --> B{Cabe en el gamut de destino}
B -- Si --> Z[Se pinta tal cual]
B -- No --> C{Claridad mayor o igual que uno}
C -- Si --> W[Blanco del destino]
C -- No --> D{Claridad menor o igual que cero}
D -- Si --> K[Negro del destino]
D -- No --> E[Busqueda binaria sobre el croma]
E --> F[Se recorta el candidato por canal]
F --> G{Distancia entre candidato y recorte por debajo del umbral}
G -- Si --> Y[Se devuelve el recorte]
G -- No --> H[Se reduce el croma y se repite]
H --> E
style Z fill:#a6e3a1,color:#11111b
style Y fill:#a6e3a1,color:#11111b
style W fill:#94e2d5,color:#11111b
style K fill:#94e2d5,color:#11111b
style E fill:#89b4fa,color:#11111b
style F fill:#89b4fa,color:#11111b
style G fill:#f9e2af,color:#11111b

Las dos ramas de salida rápida de arriba no son casos de esquina inútiles: evitan que un color con claridad fuera de rango entre en el bucle, donde la búsqueda no convergería a nada sensato.

⚠️
Las implementaciones no han convergido

El algoritmo está especificado, y eso no significa que todos los motores lo apliquen en todas las rutas de pintado. Hay caminos —fondos sólidos, texto, degradados, bordes— que en algunos navegadores siguen recortando por canal. Si un color de tu paleta está fuera de sRGB, la única forma honesta de saber qué se ve es abrirlo en cada navegador que te importe con una pantalla sRGB. No lo des por supuesto en ninguna dirección.

Diseñar con el mapeo en mente

Tres consecuencias prácticas.

El valor calculado no está mapeado. getComputedStyle de un elemento con color: oklch(70% 0.3 150) te devuelve ese mismo color, no su sustituto. El mapeo ocurre al pintar y no deja rastro en el modelo de estilo. Si tu código JavaScript calcula contraste leyendo estilos computados, está midiendo un color que quizá nadie ve.

Fuera del gamut, la distancia se comprime. Dos colores separados en P3 pueden acabar juntos o iguales en sRGB. Por eso todo lo que codifique información —niveles de severidad, pasos de una escala, categorías de un gráfico— tiene que estar dentro de sRGB por construcción. El gamut ancho sirve para que algo se vea mejor, no para distinguir.

Es más barato acotar que confiar. Si generas colores derivando croma, pon un techo:

:root { --c-max: 0.16; }

.acento {
  --base: oklch(58% 0.14 264);
  background: oklch(from var(--base) l min(calc(c * 1.3), var(--c-max)) h);
}

Con min() sabes exactamente qué color se pinta. Sin él dependes de un algoritmo cuyo comportamiento varía entre motores.

El mapeo no es una función, y por eso no puedes deshacerlo ni razonar sobre él a la ligera

Hay una propiedad matemática del mapeo de gamut que explica casi todos los problemas raros de color en producción, y es que no es inyectivo: muchos colores de entrada producen la misma salida. Eso trae tres consecuencias que se descubren por las malas. La primera es que no se puede invertir. Dado el color que ves en una pantalla sRGB no hay forma de recuperar el que se pidió, así que cualquier herramienta que lea píxeles renderizados —un test de regresión visual, una captura, un extractor de paleta— está trabajando con información que ya ha perdido lo que la distinguía. Dos capturas idénticas no prueban que dos CSS sean equivalentes. La segunda es que no conmuta con la interpolación. Mapear los dos extremos de un degradado y luego interpolar da un resultado distinto de interpolar en el espacio original y mapear cada punto. La especificación manda hacer lo segundo, que es lo correcto, y de ahí sale un efecto que desconcierta: un degradado entre dos colores fuera de gamut puede tener puntos intermedios más saturados que sus extremos, porque los extremos se comprimieron más que el centro. No es un fallo del navegador, es geometría. Y la tercera, la que más caro sale en revisión de accesibilidad, es que el mapeo cambia la luminancia. El recorte por canal la mueve varios puntos, y hasta la reducción de croma, que conserva la claridad perceptual con exactitud, altera ligeramente la luminancia relativa, porque L de Oklab y la Y de XYZ no son la misma magnitud. Un par de colores que medías con 4.51 de contraste sobre los valores declarados puede quedarse en 4.4 al pintarse en una pantalla estrecha. La conclusión operativa es incómoda pero corta: si un color tiene que cumplir algo verificable, tiene que estar dentro de sRGB. No cerca del borde: dentro, con margen. Todo lo demás puede vivir fuera y aprovechar la pantalla que tenga cada uno.

⚔️ Observa el mapeo
  1. Pinta oklch(65% 0.3 29) y oklch(70% 0.35 29) uno al lado del otro y míralos en una pantalla sRGB.
  2. Lee con getComputedStyle el color de un elemento fuera de gamut y compáralo con el píxel real.
  3. Haz un degradado entre dos colores fuera de gamut y busca si el centro se ve más vivo que los extremos.
  4. Coge tu paleta y lista qué colores caen fuera de sRGB. Decide para cada uno si codifica información.