Módulo 4: Scales Color And Contrast
Elegir pares de color accesibles
Descripción
Ya sabes medir el contraste (lección 6) y qué umbral debe superar (lección 5). Esta lección junta las dos cosas en la tarea real: auditar los pares de color de un componente y arreglar el que falla. Vas a tomar los pares del product-card de Mercado —el nombre del producto sobre la tarjeta, el precio, el texto del botón sobre su fondo azul, y un texto gris secundario—, medirlos con contrastRatio, compararlos con passesWCAG, y encontrar el que no llega a AA. Y vas a arreglarlo de la forma que un sistema permite: no inventando un color, sino subiendo de step en la rampa (de gray.400 a gray.500). Al terminar, sabrás elegir pares accesibles de una paleta y convertir un "se ve bien pero falla" en un "pasa, medido".
Conexión con el módulo. Es la síntesis de la mitad de contraste del módulo: usa el algoritmo de la lección 6, los umbrales de la 5, y las rampas de la 4 (arreglar un par es moverse por la rampa). Cierra el arco antes del proyecto: la lección 8 hace esta misma auditoría como entregable, sobre las tres escalas que definirás. Aquí lo ves de punta a punta con los pares concretos de Mercado.
Una analogía: elegir la tinta según el papel
Un impresor no tiene "una tinta que se lee". Tiene tintas y papeles, y la legibilidad nace de la combinación: tinta negra sobre papel blanco se lee perfecto; la misma tinta negra sobre papel azul oscuro, casi no. La tinta no es buena ni mala en abstracto —depende del papel debajo—. Por eso el impresor no elige la tinta sola: elige el par tinta-papel, y sabe por experiencia (o mide) cuáles se leen.
Elegir pares de color accesibles es exactamente eso. Un color de tu paleta no "es accesible": lo es sobre cierto fondo. El azul de Mercado (blue.600) como texto sobre blanco se lee (pasa AA); el mismo azul como texto sobre blue.400 no. Así que no auditas colores sueltos: auditas pares (frente sobre fondo). Y cuando un par falla, tienes dos palancas —cambiar la tinta o cambiar el papel—, que en una paleta significa: mover el texto a un step más oscuro, o el fondo a uno más claro. Subir la distancia entre sus steps sube el contraste, porque el contraste es distancia de claridad (lección 4). El impresor cambia de tinta o de papel; tú cambias de step.
Guarda la imagen: la accesibilidad es del par (tinta sobre papel / frente sobre fondo), no del color solo; cuando un par falla, lo arreglas aumentando la distancia de steps —texto más oscuro o fondo más claro—, no inventando un color nuevo.
Ejemplo trabajado: auditar el product-card
Aquí auditamos los cuatro pares del product-card de Mercado. Cada par es frente-sobre-fondo, con los colores que vienen de los tokens de los módulos 2–4:
card.text / card.bg: el nombre del producto,gray.900(#111827) sobre la tarjeta blanca (#ffffff).price / card.bg: el precio, enblue.600(#2563eb,text-primary) sobre blanco.button.txt / button.bg: el "Add to cart", texto blanco (#ffffff) sobre el fondoblue.600.muted.text / card.bg: un texto gris secundario elegido "a ojo",gray.400(#9ca3af) sobre blanco.
Reunimos contrastRatio (lección 6) y passesWCAG (lección 5) y corremos la auditoría:
// L7 — elegir pares accesibles: auditar los colores del product-card contra WCAG.
function hexToRgb(hex) { const n = parseInt(hex.slice(1), 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; }
function linearize(ch) { const c = ch / 255; return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); }
function relativeLuminance(hex) { const [r, g, b] = hexToRgb(hex).map(linearize); return 0.2126 * r + 0.7152 * g + 0.0722 * b; }
function contrastRatio(fg, bg) {
const L1 = relativeLuminance(fg), L2 = relativeLuminance(bg);
return (Math.max(L1, L2) + 0.05) / (Math.min(L1, L2) + 0.05);
}
function passesWCAG(ratio, { large } = {}) {
const aa = large ? 3 : 4.5, aaa = large ? 4.5 : 7;
if (ratio >= aaa) return 'AAA';
if (ratio >= aa) return 'AA';
return 'FAIL';
}
const r2 = (x) => Math.round(x * 100) / 100;
// los pares del product-card de Mercado: rol -> [texto/frente, fondo, ¿grande?].
const pairs = [
{ name: 'card.text / card.bg', fg: '#111827', bg: '#ffffff', large: false }, // nombre del producto
{ name: 'price / card.bg', fg: '#2563eb', bg: '#ffffff', large: false }, // precio (text-primary)
{ name: 'button.txt / button.bg', fg: '#ffffff', bg: '#2563eb', large: false }, // "Add to cart"
{ name: 'muted.text / card.bg', fg: '#9ca3af', bg: '#ffffff', large: false }, // texto gris secundario
];
console.log('=== auditoria de contraste del product-card ===\n');
console.log('par'.padEnd(24) + 'ratio'.padEnd(10) + 'nivel (texto normal)');
console.log('-'.repeat(50));
for (const p of pairs) {
const ratio = contrastRatio(p.fg, p.bg);
const level = passesWCAG(ratio, { large: p.large });
const mark = level === 'FAIL' ? ' <-- NO pasa AA' : '';
console.log(p.name.padEnd(24) + (r2(ratio) + ':1').padEnd(10) + level + mark);
}
Qué esperar. Al correr el archivo con Node, la salida es exactamente esta:
=== auditoria de contraste del product-card ===
par ratio nivel (texto normal)
--------------------------------------------------
card.text / card.bg 17.74:1 AAA
price / card.bg 5.17:1 AA
button.txt / button.bg 5.17:1 AA
muted.text / card.bg 2.54:1 FAIL <-- NO pasa AA
Lee la tabla como el diagnóstico de accesibilidad de la tarjeta. El nombre del producto (gray.900 sobre blanco) da 17.74:1 —AAA con muchísima holgura, como debe ser el texto principal—. El precio y el botón dan el mismo 5.17:1: uno es azul sobre blanco, el otro blanco sobre azul, y como el ratio es simétrico (lección 6), el par blue.600/white da lo mismo en cualquier orden. Ambos pasan AA (superan 4.5) pero no AAA (no llegan a 7) —correcto y suficiente para un botón; AAA es deseable pero AA es el mínimo exigible—.
Y ahí está el problema: muted.text / card.bg da 2.54:1 y falla AA. El gris #9ca3af (gray.400) se eligió "a ojo" porque se ve sutil y elegante para un texto secundario —una fecha, un "vendido por", un dato menor—. Pero sutil y elegante, sobre blanco, significa poca distancia de claridad, y 2.54 está muy por debajo del 4.5 mínimo. Un lector con baja visión no lo distingue del fondo. "Se ve bien" y "es legible" no son lo mismo, y el número lo prueba: los otros tres pares se ven bien y pasan; este se ve bien y falla. Sin medir, nadie lo habría notado hasta recibir una queja.
Una pregunta antes de arreglarlo: ¿por qué el precio en azul solo llega a AA y no a AAA, si el nombre en gris oscuro llega a AAA? (Porque blue.600 es un azul saturado y de claridad media, no oscuro. Su luminancia es más alta que la de gray.900 (casi negro), así que su distancia al blanco es menor —5.17 contra 17.74—. Un color de marca vibrante rara vez llega a AAA sobre blanco; para eso tendrías que oscurecerlo tanto que dejaría de verse "de marca". AA es el objetivo realista para el color primario; AAA lo reservas para el texto de lectura.)
Profundización: cómo arreglar el par que falla
El par muted.text falla, y la tentación es "aclarar un poco el fondo" o "teclear un gris que se vea igual pero pase". Las dos son trampas: la primera cambia el diseño de la tarjeta, la segunda reintroduce un valor suelto fuera de la paleta. La forma correcta, la que un sistema permite, es subir de step en la rampa gray: si gray.400 (#9ca3af) es demasiado claro, prueba gray.500 (#6b7280), el siguiente step más oscuro. Más oscuro = más distancia de claridad al blanco = más contraste.
// el fix: subir el texto muted de gray.400 a gray.500 (mas oscuro).
console.log(contrastRatio('#9ca3af', '#ffffff').toFixed(2)); // gray.400 -> 2.54 FAIL
console.log(contrastRatio('#6b7280', '#ffffff').toFixed(2)); // gray.500 -> 4.83 AA
Qué esperar al medir los dos:
2.54 (gray.400, FAIL)
4.83 (gray.500, AA)
gray.500 da 4.83:1 —supera el 4.5 de AA—. Un solo step de la rampa convirtió un texto ilegible en uno accesible, sin inventar ningún color y sin tocar el fondo. Y como gray.500 sigue siendo un gris (no negro), conserva el aire "secundario" que buscabas —apenas más oscuro, pero ahora legible—. Esa es la ventaja de tener una rampa: el arreglo es moverse por ella, no salirse. El texto secundario de Mercado pasa a ser gray.500, y ese se vuelve el valor de color.muted para texto.
Nota un matiz que la lección 5 anticipó: si ese texto secundario fuera grande (un text-2xl, ≥24px), el umbral bajaría a 3:1, y gray.400 (2.54) seguiría fallando —pero un gray.450 hipotético con 3.1 ya pasaría—. El umbral que aplicas depende del tamaño del texto (su token tipográfico de la lección 3). Para texto secundario chico, que es lo normal, necesitas AA normal (4.5), y por eso gray.500 es la elección correcta.
Un principio general para elegir pares de una paleta, que puedes llevar a cualquier sistema:
Regla practica de distancia de steps (rampas tipo 50..900, sobre fondo claro):
texto normal sobre blanco -> step >= 500-600 (p.ej. gray.600, blue.700) apunta a AA/AAA
texto grande sobre blanco -> step >= 400-500 (el tamano afloja el umbral)
texto secundario legible -> gray.500 minimo (gray.400 casi siempre falla)
boton de color de marca -> texto blanco sobre step 500-700 del color
Es una guia, NO un reemplazo de medir: mide siempre el par exacto con contrastRatio.
Esta tabla es una intuición para empezar, no un veredicto —los steps exactos que pasan dependen del tono (un amarillo .500 es mucho más claro que un azul .500) y del fondo—. La regla dura sigue siendo la de todo el módulo: elige un par candidato con la intuición, y mídelo. El número manda.
Errores comunes
Elegir colores "que se ven bien" sin medir y fallar AA. Qué pasa: se pinta el texto secundario de un gris claro elegante (gray.400), se ve precioso en el monitor del diseñador, y falla AA sin que nadie lo note. Por qué pasa: en un monitor bueno, con buena vista, 2.54:1 "se lee" —para ti—. Cómo detectarlo: no hay ningún número en tu proceso; el color se aprobó por impresión. Cómo corregirlo: mide todo par de texto antes de aprobarlo. gray.400 sobre blanco es el ejemplo canónico: universalmente usado para texto "sutil", universalmente fallando AA (2.54). Si tu diseño tiene texto gris claro sobre blanco, es casi seguro que hay un fallo de contraste esperando. Mídelo; sube de step si falla.
Arreglar el fallo inventando un color o cambiando el diseño. Qué pasa: el par falla y se "arregla" tecleando un gris a medida (#7a7a7a) hasta que pase, o aclarando el fondo de la tarjeta. Por qué pasa: parece más rápido que buscar el step correcto. Cómo detectarlo: tu arreglo introdujo un hex que no está en ninguna rampa, o cambió un color de fondo que afecta a otras cosas. Cómo corregirlo: el arreglo correcto es subir de step en la rampa (gray.400 → gray.500), que mantiene el color dentro del sistema y coherente con todo lo demás. Un gris inventado #7a7a7a puede pasar AA hoy, pero es un valor suelto —el drift del módulo 1— que nadie más conoce ni puede reusar. La rampa ya tiene el gris que necesitas; úsalo.
Medir contra el fondo equivocado. Qué pasa: se aprueba el texto secundario gray.500 porque "pasa sobre blanco" (4.83), pero en una variante de la tarjeta ese texto va sobre el fondo muted gris claro, o sobre un badge azul —y ahí no pasa—. Por qué pasa: se mide una vez, contra el fondo más común, y se asume que vale para todos. Cómo detectarlo: el texto "aprobado" se ve mal en alguna variante del componente. Cómo corregirlo: mide el par contra cada fondo real donde el texto aparece. gray.500 da 4.83:1 sobre blanco (pasa) pero 1.07:1 sobre blue.600 (invisible) —el mismo texto, dos fondos, dos veredictos opuestos—. Si un texto vive sobre varios fondos, cada combinación es un par que auditar.
Ejercicios
Ejercicio 1 — Diagnostica el par. Sin correr nada, usando lo que viste (gray.900→17.74, blue.600→5.17 sobre blanco, gray.400→2.54, gray.500→4.83), di si cada par pasa AA para texto normal y por qué:
- (a) Texto
gray.900sobrewhite. - (b) Texto
gray.400sobrewhite. - (c) Texto blanco sobre
blue.600.
Ver solución
- (a) Pasa (AAA).
17.74:1supera de sobra el 4.5 de AA y el 7 de AAA. Es el texto principal; debe tener el máximo contraste. - (b) Falla.
2.54:1está muy por debajo de 4.5. Es el gris "elegante" que no se lee; hay que subir de step. - (c) Pasa (AA). Blanco sobre
blue.600da5.17:1(el mismo ratio queblue.600sobre blanco, porque es simétrico). Supera 4.5 pero no 7, así que AA, no AAA —suficiente para un botón—.
Ejercicio 2 — Elige el step del fix. El texto secundario en gray.400 (2.54) falla. Sabiendo que gray.500 da 4.83 y que los steps más oscuros dan más contraste, sin correr nada responde: (a) ¿gray.500 es suficiente para texto normal? (b) si quisieras que ese texto secundario llegara a AAA (7:1), ¿bastaría gray.500, o necesitarías subir más? (Dato: gray.600 = #4b5563 da 7.56:1.)
Ver solución
- (a) Sí.
gray.500da4.83:1, que supera el4.5de AA para texto normal. Es el fix mínimo correcto para legibilidad accesible. - (b) No bastaría
gray.500(4.83 < 7). Para AAA necesitarías subir agray.600(#4b5563,7.56:1), que supera el7. El precio de llegar a AAA es un gris más oscuro —menos "secundario" visualmente—. Ahí decides: para texto secundario, AA (gray.500) suele ser el equilibrio correcto entre legibilidad y jerarquía; AAA (gray.600) si el contenido es importante o el público lo necesita. El número te muestra el costo de cada nivel.
Ejercicio 3 — Un par nuevo. Mercado agrega una etiqueta de "oferta" con texto sobre un fondo de marca blue.600 (#2563eb). El diseñador propone texto en blue.900 (azul oscuro) para que "combine con la marca". Sin correr nada, razona: (a) ¿por qué es sospechoso ese par? (b) ¿qué color de texto elegirías y por qué? (Dato de la lección: blanco sobre blue.600 da 5.17:1.)
Ver solución
- (a) Es sospechoso porque
blue.900yblue.600son el mismo tono a distinta claridad —dos steps de la misma rampa, relativamente cercanos—. Poco distancia de claridad = poco contraste. Texto azul oscuro sobre fondo azul medio casi seguro falla: los dos "azules de marca" se funden. "Combina con la marca" es justo el instinto estético que la medición desmiente. - (b) Texto blanco (o un step muy claro como
blue.50), que da5.17:1sobreblue.600—pasa AA—. La regla es la de siempre: mucha distancia de claridad. Fondo de color medio → texto claro (blanco); fondo claro → texto oscuro. Combinar dos steps cercanos de la misma rampa para texto es el error a evitar. Y, como siempre: mídelo antes de aprobarlo.
Resumen y siguiente paso
En esta lección aprendiste a elegir pares de color accesibles auditando el product-card de Mercado. Mediste sus cuatro pares con contrastRatio y los clasificaste con passesWCAG: el nombre (gray.900/blanco) dio 17.74:1 AAA, el precio y el botón (blue.600/blanco) dieron 5.17:1 AA, y el texto secundario (gray.400/blanco) falló con 2.54:1. Con la tinta sobre el papel viste que la accesibilidad es del par, no del color, y que se arregla aumentando la distancia de steps: subiste el texto de gray.400 a gray.500 (4.83:1, AA) sin inventar un color ni tocar el fondo. Elegir pares accesibles es medir el par y, si falla, moverse por la rampa.
Antes de avanzar deberías poder: auditar un par con contrastRatio + passesWCAG; reconocer que gray.400 sobre blanco es el fallo clásico; y arreglar un par subiendo de step en vez de inventando un color o cambiando el diseño.
La lección 8 es el proyecto: definir las tres escalas de Mercado —espaciado (base 4px), tipográfica (modular), y la paleta de color por steps— y auditar el contraste del product-card de punta a punta, marcando cuáles pares pasan AA/AAA y arreglando el que falla. Es todo el módulo en un solo entregable ejecutado: las escalas que dan coherencia (L2–L4) y el contraste que garantiza accesibilidad (L5–L7), juntos, sobre el componente real de Mercado.
Recursos
- WebAIM, "Contrast Checker" — webaim.org/resources/contrastchecker. Pega los pares de Mercado y confirma los ratios de la auditoría (
blue.600/blanco = 5.17,gray.400/blanco = 2.54). En inglés. - W3C, "Contrast (Minimum)" (WCAG 2.1, 1.4.3) — w3.org/WAI/WCAG21/Understanding/contrast-minimum.html. El umbral AA que decide si tus pares pasan; la referencia normativa. En inglés.
- Tailwind CSS, "Colors" — tailwindcss.com/docs/colors. Las rampas
grayybluepor las que te mueves al arreglar un par; ubicagray.400,gray.500,gray.600. En inglés. - Who Can Use — whocanuse.com. Muestra cómo se ve un par de colores para distintos tipos de visión (daltonismo, baja visión); el "para quién" del contraste de la lección 5. En inglés.