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

Derivar hover, disabled y todos los estados de un único token

Un componente completo cuyo color entero, en los dos temas y en todos sus estados, sale de una sola declaración, y el criterio para decidir qué se deriva y qué no.

⏱ 21 min

Un botón de un sistema de diseño tiene fondo, texto, borde, fondo en :hover, fondo en :active, anillo de foco, versión deshabilitada y variante sobre fondo oscuro. Son ocho colores, y en la mayoría de los proyectos son ocho tokens escritos a mano, multiplicados por cuantas variantes tenga el botón. Con from y color-mix() esos ocho se reducen a uno, y las variantes se convierten en una lista de valores en lugar de una lista de reglas.

🎯 Al terminar esta lección sabrás
  • Reducir la superficie de color de un componente a un solo token de entrada.
  • Derivar los estados moviendo la claridad hacia la superficie, no por deltas fijos.
  • Elegir automáticamente el color del texto que va encima.
  • Reconocer qué colores no deben derivarse nunca.

Un token, ocho valores

Empecemos por el planteamiento habitual, para tener con qué comparar:

.boton--primario {
  --fondo: #3364db;
  --fondo-hover: #2a55b8;
  --fondo-activo: #224d9e;
  --fondo-inactivo: #b8c6e8;
  --texto: #ffffff;
  --borde: #2a55b8;
  --anillo: #7fa0ee;
}

Siete valores. Cada variante nueva del botón —secundario, peligro, éxito— son siete más. Con seis variantes y dos temas son ochenta y cuatro números, y ninguna garantía de que la relación entre ellos sea la misma en todos.

Ahora la versión derivada:

.boton {
  --base: oklch(54% 0.19 264);
  --superficie: Canvas;

  background: var(--base);
  color: oklch(from var(--base) clamp(0, calc(1.2 - l * 2), 1) 0 h);
  border: 1px solid color-mix(in oklab, var(--base), CanvasText 12%);

  &:hover  { background: color-mix(in oklab, var(--base), CanvasText 10%); }
  &:active { background: color-mix(in oklab, var(--base), CanvasText 18%); }

  &:focus-visible {
    outline: 2px solid color-mix(in oklab, var(--base), var(--superficie) 35%);
    outline-offset: 2px;
  }

  &:disabled {
    background: color-mix(in oklab, var(--base) 30%, var(--superficie));
    color: color-mix(in oklab, CanvasText 45%, var(--superficie));
  }
}

.boton--peligro { --base: oklch(54% 0.19 29); }
.boton--exito   { --base: oklch(54% 0.13 142); }
.boton--neutro  { --base: oklch(54% 0.02 264); }

Cuatro variantes en cuatro líneas. Todas comparten exactamente las mismas relaciones: el :hover está siempre a la misma distancia del reposo, el borde tiene siempre el mismo contraste con el fondo, el estado deshabilitado se apaga siempre igual.

Mover hacia la superficie, no por delta

Fíjate en cómo están escritos los estados. No dicen “resta 0.06 a la claridad”: dicen “mezcla un 10% hacia el color del texto”. La diferencia parece cosmética y es estructural.

Restar un delta a la claridad es una traslación, y una traslación puede sacarte del rango. Un botón cuya base ya sea muy oscura llega a claridad negativa en el :active y se recorta a negro, con lo que :hover y :active se ven iguales. Un botón muy claro tiene el problema simétrico.

Mezclar hacia una referencia es una interpolación, y una interpolación nunca sale del segmento. El resultado está siempre entre la base y la referencia, sea cual sea la base. Un botón oscuro se oscurece menos en términos absolutos, pero se oscurece; un botón claro también. La relación se mantiene y nada se sale.

La elección de la referencia es lo que hace que funcione en los dos temas. CanvasText es negro en tema claro y casi blanco en tema oscuro. Mezclar hacia CanvasText significa “aléjate del fondo”, que es lo que un :hover debe hacer en ambos casos. Con una constante black el tema oscuro se rompe: sobre fondo oscuro, oscurecer un botón lo hace desaparecer.

💡
Los colores de sistema no son solo Canvas y CanvasText

CSS Color 4 define un catálogo completo que responde al esquema de color y al modo de alto contraste del sistema operativo: Canvas, CanvasText, LinkText, VisitedText, ActiveText, ButtonFace, ButtonText, ButtonBorder, Field, FieldText, Highlight, HighlightText, Mark, MarkText, GrayText y AccentColor con su AccentColorText. Usados como referencia de mezcla resuelven en una declaración lo que si no exige duplicar toda la hoja bajo una consulta de esquema.

El texto que va encima

La línea del color del botón merece explicación porque es el truco que más se copia mal:

color: oklch(from var(--base) clamp(0, calc(1.2 - l * 2), 1) 0 h);

Lee la claridad de la base, la mete en una función lineal decreciente y acota el resultado entre 0 y 1. Con la base en claridad 0.54 el cálculo da 1.2 - 1.08, es decir 0.12: casi negro. Con la base en 0.3 da 0.6, un gris claro. Con la base en 0.85 da negativo, que clamp() sube a 0: negro. El croma se fija a cero, así que siempre es un gris neutro del mismo tono.

El resultado es un texto que cambia de blanco a negro solo al cruzar la claridad 0.6 de la base, y que además hereda el tono. Es el algoritmo de contraste del pobre y funciona sorprendentemente bien para fondos de croma medio.

Lo que no hace es garantizar un ratio de contraste WCAG. El umbral 0.6 es una aproximación empírica, no una medición. Para un componente donde el contraste sea un requisito verificable, lo correcto sigue siendo declarar los dos colores y medirlos.

Hay un caso donde el truco falla de forma llamativa: los amarillos y los cianes muy saturados. Su claridad perceptual está por encima de 0.85, con lo que el texto sale negro, que es correcto; pero el mismo tono a claridad 0.55 ya está fuera del gamut y lo que se pinta no es el color que pediste, así que el cálculo se hizo sobre un valor que no coincide con lo que se ve.

El sistema completo

Un tema es una lista de ángulos

Con la técnica montada, un tema entero cabe en una lista de ángulos y dos parámetros:

@layer tokens {
  :root {
    --c-solido: 0.19;
    --l-solido: 54%;
  }

  .accion   { --base: oklch(var(--l-solido) var(--c-solido) 264); }
  .peligro  { --base: oklch(var(--l-solido) var(--c-solido) 29); }
  .aviso    { --base: oklch(var(--l-solido) var(--c-solido) 70); }
  .exito    { --base: oklch(var(--l-solido) var(--c-solido) 142); }
  .info     { --base: oklch(var(--l-solido) var(--c-solido) 220); }
}

@layer componentes {
  .chip {
    background: color-mix(in oklab, var(--base) 15%, Canvas);
    color: color-mix(in oklab, var(--base), CanvasText 25%);
    border: 1px solid color-mix(in oklab, var(--base) 35%, Canvas);
  }
}

El .chip no conoce ningún color. Conoce una relación con --base, y --base la pone quien lo use. Escribir un componente nuevo es escribir tres relaciones, no quince tokens.

Lo que no se deriva

Tres excepciones que conviene tener claras, porque la tentación de derivarlo todo es fuerte.

Los colores con requisito legal o de accesibilidad no se derivan, se declaran y se miden. Si un texto tiene que cumplir 4.5, ese par de colores es un dato verificado, no el resultado de una fórmula. Puedes derivar el resto de la interfaz alrededor.

Los colores de marca no se derivan del token de interfaz. Van al revés: la marca es la entrada. Derivar el logo de un color de acento que alguien puede cambiar es pedir que un día el logo salga mal.

Los estados de error no se derivan del color principal. Un error tiene que verse distinto de todo lo demás, y si sale de la misma raíz que el resto acabará pareciéndose demasiado cuando alguien mueva la raíz.

La cadena de derivación tiene que ser corta y en un solo sentido

El fallo que aparece a los seis meses en cualquier sistema derivado no es un color mal calculado: es un grafo de dependencias que ha crecido sin que nadie lo mirase. Empiezas con --base, defines --hover derivado de ella, luego --hover-borde derivado del :hover, luego alguien necesita un --hover-borde-inactivo y lo deriva de aquel. Cuatro saltos. Cada salto es una mezcla, y cada mezcla acerca el color al punto de referencia, así que a la cuarta ya no distingues el resultado de un gris. Peor: cuando alguien cambia --base, el cambio llega al último eslabón atenuado por cuatro interpolaciones, con lo que el sistema responde cada vez menos a su propia entrada. Los síntomas son inconfundibles y se diagnostican mal: “el tema oscuro se ve plano”, “los estados no se notan”, “el color de marca no se aprecia en la interfaz”. La regla que lo evita es aburrida y funciona: como máximo un salto desde el token de entrada. Todo lo que un componente necesite se deriva directamente de --base, nunca de otro derivado. Si necesitas un borde para el :hover, no lo saques del fondo del :hover: sácalo de --base con una mezcla mayor. Cuesta escribir un porcentaje más grande y garantiza que el sistema responde linealmente a su entrada. El corolario práctico es que la lista de derivaciones de un componente debería caber en una pantalla y leerse como una tabla, con --base en cada línea. En cuanto veas un var(--algo-derivado) dentro de otra derivación, el grafo ha dejado de ser un árbol de profundidad uno y ya no puedes razonar sobre él de un vistazo.

⚔️ Reduce un componente a un token
  1. Coge un botón de tu sistema con todas sus variantes y cuenta cuántos valores de color tiene.
  2. Reescríbelo con una sola --base por variante y mezclas hacia Canvas y CanvasText.
  3. Comprueba que funciona en tema oscuro sin escribir ninguna consulta de esquema.
  4. Prueba la fórmula del texto automático con una base muy clara y con otra muy oscura.
  5. Dibuja el grafo de dependencias de tus colores y busca cualquier cadena de más de un salto.