Construir una paleta en OKLCH y la misma en HSL, lado a lado
Una escala de once pasos y una familia de seis tonos escritas en los dos espacios, con los contrastes medidos, para ver exactamente qué se rompe en HSL y por qué.
La teoría del espacio perceptual se justifica en un único sitio: cuando te sientas a escribir una paleta. Aquí está esa paleta, completa y comprobable, con sus once pasos y sus seis tonos, escrita primero en OKLCH y después en HSL con la mejor voluntad posible. Los números de contraste están medidos, no estimados, y son la razón por la que ningún sistema de diseño serio genera color en HSL desde hace años.
- Construir una escala de once pasos de un tono con
Lrepartida yCen curva. - Reproducir esa escala en HSL y medir en qué se degrada.
- Generar una familia de seis tonos con el mismo peso visual.
- Reconocer el límite de la técnica y qué hay que medir aparte.
Una escala de once pasos
El diseño de la escala son dos decisiones. La primera es cómo repartir la claridad: pasos regulares, con algo más de separación en la zona media, que es donde el ojo discrimina mejor. La segunda es la curva de croma: cero en los extremos, máximo un poco por debajo del cusp del tono. Para el azul de 264 grados el cusp está en L 0.49, así que el máximo de croma cae hacia el paso 600.
:root {
--azul-50: oklch(97% 0.013 264);
--azul-100: oklch(94% 0.028 264);
--azul-200: oklch(88% 0.055 264);
--azul-300: oklch(80% 0.090 264);
--azul-400: oklch(71% 0.130 264);
--azul-500: oklch(62% 0.180 264);
--azul-600: oklch(54% 0.190 264);
--azul-700: oklch(46% 0.175 264);
--azul-800: oklch(38% 0.145 264);
--azul-900: oklch(30% 0.105 264);
--azul-950: oklch(22% 0.070 264);
}
Los once están dentro del gamut de sRGB, y el margen que dejan respecto al techo es intencionado. Estos son los valores medidos:
| Paso | Equivalente sRGB | Croma máximo disponible | Contraste sobre blanco |
|---|---|---|---|
| 50 | #f1f5fe |
0.014 | 1.09 |
| 100 | #e2ecff |
0.028 | 1.19 |
| 200 | #c5d8fd |
0.058 | 1.44 |
| 300 | #a0bef9 |
0.100 | 1.88 |
| 400 | #789ff3 |
0.151 | 2.60 |
| 500 | #4d7ff1 |
0.204 | 3.74 |
| 600 | #3364db |
0.255 | 5.26 |
| 700 | #224db8 |
0.267 | 7.41 |
| 800 | #183a8e |
0.220 | 10.33 |
| 900 | #112961 |
0.174 | 13.87 |
| 950 | #09183b |
0.128 | 17.43 |
Fíjate en la tercera columna. En los pasos 50 y 100 el croma que usamos es prácticamente el máximo posible: la geometría del gamut no da para más y esa es la razón de que la curva empiece plana, no una preferencia estética. En los pasos centrales sobra sitio, y ahí es donde decides el carácter de la escala.
La progresión de contraste es la prueba de que la escala está bien repartida: 1.09, 1.19, 1.44, 1.88, 2.60, 3.74, 5.26, 7.41, 10.33, 13.87, 17.43. Sube de forma suave y sin escalones, con lo que puedes elegir el paso por su número y saber aproximadamente qué contraste te da.
La misma escala en HSL
Ahora en HSL, poniendo empeño. El tono equivalente es el 220 y hay que retocar la claridad paso a paso hasta que la rampa suba de forma regular. Esto es lo que sale tras el retoque:
:root {
--azul-50: hsl(220 90% 95%);
--azul-100: hsl(220 90% 88%);
--azul-200: hsl(220 90% 78%);
--azul-300: hsl(220 90% 66%);
--azul-400: hsl(220 90% 55%);
--azul-500: hsl(220 90% 46%);
--azul-600: hsl(220 90% 38%);
--azul-700: hsl(220 90% 31%);
--azul-800: hsl(220 90% 24%);
--azul-900: hsl(220 90% 17%);
--azul-950: hsl(220 90% 12%);
}
Y funciona. Medida en claridad perceptual, esta rampa da 95, 88, 78, 66, 57, 50, 44, 38, 32, 26, 21: una progresión razonable. El precio ya está pagado, eso sí, en el tiempo de la persona que ajustó once números a ojo.
El problema aparece cuando reutilizas esos mismos once valores de L para otro tono, que es exactamente lo que hace cualquiera al añadir el rojo, el verde y el ámbar del sistema:
L de HSL |
Tono 220 | Tono 50 | Tono 140 | Tono 300 |
|---|---|---|---|---|
| 95% | 95 | 98 | 98 | 95 |
| 78% | 78 | 93 | 90 | 81 |
| 55% | 57 | 86 | 85 | 69 |
| 46% | 50 | 80 | 79 | 64 |
| 31% | 38 | 60 | 59 | 48 |
| 17% | 26 | 39 | 38 | 31 |
La columna del 220 sube como debe. La del 50 y la del 140 están desplazadas treinta puntos hacia arriba en toda la zona media: su paso “500” no es un color medio, es un color claro. La del 300 va por su cuenta. Es decir: los once valores de claridad que tanto costó ajustar solo valen para el tono para el que se ajustaron, y hay que repetir el trabajo para cada familia de color. Eso, multiplicado por diez tonos y dos temas, es la razón por la que las paletas HSL acaban siendo una tabla de doscientos números escritos a mano que nadie se atreve a tocar.
Una familia de tonos con el mismo peso
El caso donde la diferencia es más brutal es la fila horizontal: los seis colores semánticos del sistema, todos con el mismo peso, para que ninguno domine.
En OKLCH es una línea de código repetida seis veces con un solo número distinto:
:root {
--rojo: oklch(55% 0.09 29);
--ambar: oklch(55% 0.09 70);
--verde: oklch(55% 0.09 142);
--teal: oklch(55% 0.09 195);
--azul: oklch(55% 0.09 264);
--morado: oklch(55% 0.09 328);
}
Medidos, esos seis dan un contraste sobre blanco de 5.06, 4.94, 4.67, 4.64, 4.89 y 5.08. Todos superan 4.5 y ninguno se aparta medio punto de los demás. Son seis colores que puedes intercambiar en un icono, en una etiqueta o en un borde sin que la jerarquía visual de la página cambie.
El intento equivalente en HSL, con la misma saturación y la misma claridad para los seis:
:root {
--rojo: hsl(0 60% 45%);
--ambar: hsl(45 60% 45%);
--verde: hsl(120 60% 45%);
--teal: hsl(180 60% 45%);
--azul: hsl(240 60% 45%);
--morado: hsl(300 60% 45%);
}
Contrastes medidos: 6.08, 2.85, 2.63, 2.43, 9.57 y 5.11. El azul tiene casi cuatro veces el contraste del teal. Tres de los seis no llegan a 3 y no sirven para texto sobre blanco, mientras el azul es tan oscuro que parece de otra escala. Puestos en fila, el azul y el rojo se comen visualmente a los otros cuatro.
El croma de la familia está en 0.09 porque a L igual a 0.55 el tono 195 no da más: su máximo en sRGB es 0.094. Los otros cinco tonos aguantarían el doble o el triple. Cuando fijas croma constante en una familia multitono, manda el tono más estrecho, y en sRGB ese suele ser el cian o el amarillo. Si necesitas más intensidad, la alternativa es normalizar el croma como fracción del máximo de cada tono, aceptando que entonces la familia ya no es de croma constante sino de saturación constante.
Automatizar la escala
Con los tres canales sueltos, la escala entera se parametriza y el sistema se reduce a una plantilla y una lista de tonos:
@layer tokens {
:root {
--c-baja: 0.09;
--c-media: 0.18;
--escala-l-50: 97%;
--escala-l-500: 62%;
--escala-l-900: 30%;
}
.tema {
--azul-50: oklch(var(--escala-l-50) 0.013 var(--h-azul));
--azul-500: oklch(var(--escala-l-500) var(--c-media) var(--h-azul));
--azul-900: oklch(var(--escala-l-900) 0.105 var(--h-azul));
--h-azul: 264;
}
/* Un tema entero es una lista de angulos. */
.tema-calido { --h-azul: 29; }
}
Y con eso llega el verdadero cambio de método: la paleta deja de ser una tabla de valores y pasa a ser una función. Añadir un tono nuevo al sistema es añadir un ángulo. Cambiar el carácter de todos los colores a la vez es tocar dos números de croma. Rehacer el tema oscuro es reemplazar la lista de claridades. Nada de eso es posible mientras los colores sean literales opacos.
Los seis colores de la familia dan contrastes de 4.64 a 5.08 sobre blanco. Es una dispersión pequeña y por eso la técnica funciona, pero no es cero, y crece con el croma. Con croma bajo el color está cerca del eje gris y la claridad perceptual predice bien la luminancia. Con croma alto se separan: a L igual a 0.60 y croma máximo por tono, el contraste sobre blanco va de 3.77 en el teal a 4.59 en el magenta. Eso son casi ocho décimas, suficiente para que un color cruce el umbral de 4.5 y su hermano de la misma fila no lo cruce. La causa es estructural: WCAG 2 mide un cociente de luminancias relativas, que es una combinación lineal de los canales lineales con pesos 0.2126, 0.7152 y 0.0722, y esa fórmula no sabe nada de percepción cromática; OKLCH mide claridad percibida, que sí. Son dos magnitudes distintas y no hay ninguna razón para que coincidan. De ahí salen dos consecuencias prácticas. La primera: iguala L para que la paleta se vea coherente y mide el contraste aparte, color por color, para saber si cumple. Son dos requisitos independientes y ninguno implica el otro. La segunda, más útil: si necesitas que una fila entera cumpla un umbral, baja el croma antes que la claridad. Bajar croma acerca todos los colores a la luminancia que predice su L, con lo que la dispersión se estrecha y la fila entera se vuelve predecible. Un color apagado no es una concesión estética: es la forma de hacer que dos modelos distintos digan lo mismo.
- Coge la paleta de un proyecto tuyo y convierte sus once pasos de un tono a OKLCH. Anota las claridades reales.
- Redistribuye las claridades de forma regular y compara la rampa con la original.
- Construye la fila de seis colores semánticos con
LyCconstantes y mide los seis contrastes. - Encuentra el tono más estrecho de tu paleta y comprueba cuánto croma admite a la claridad que usas.
- Parametriza la escala con custom properties y genera un segundo tema cambiando solo los ángulos.