Contraste de cor no CSS: como calcular se o seu texto passa de 4,5:1
Calcule a razão de contraste entre texto e fundo, saiba qual número cada elemento precisa atingir e organize as cores em variáveis que o tema escuro consegue trocar.
O cinza #777777 em fundo branco parece perfeitamente legível no monitor de quem desenhou a página. A conta diz 4,48:1, e o mínimo para texto comum é 4,5:1: reprova por dois centésimos. Trocar para #767676 já passa. A resposta curta: escolha a cor do texto junto com a do fundo, calcule a razão de contraste e mire pelo menos 4,5:1 para texto comum e 3:1 para texto grande e bordas de controles. O hexadecimal em si é o de menos.
O exemplo deste guia é o site de uma clínica veterinária: texto principal, um cinza "suave" para datas e legendas, cards com fundo #F5F5F5 e um azul de link. Vamos medir cada par, consertar os que reprovam e deixar tudo em variáveis CSS.
Por que o cinza bonito some
O olho não lê a cor, lê a diferença de luminosidade entre a letra e o que está atrás dela. Dois tons com matizes diferentes podem ter quase a mesma luminosidade e virar uma mancha. Isso piora no celular sob sol, em tela barata com brilho baixo, para quem tem baixa visão e para qualquer pessoa acima dos 45 anos.
As diretrizes WCAG transformam isso num número. Primeiro calculam a luminância relativa de cada cor, de 0 (preto) a 1 (branco). Depois dividem a mais clara pela mais escura, somando 0,05 nas duas: (L1 + 0,05) / (L2 + 0,05). O resultado vai de 1:1 (mesma cor) a 21:1 (preto no branco).
Os números que valem
- 4,5:1 para texto comum, no nível AA, que é o que contratos e leis de acessibilidade costumam pedir.
- 3:1 para texto grande, a partir de 24px, ou cerca de 18,7px em negrito.
- 3:1 para componentes de interface: borda de campo de formulário, ícone que é o único rótulo de um botão, anel de foco.
- 7:1 para texto comum no nível AAA, mais exigente.
Como calcular a razão de contraste
A luminância vem dos três canais RGB. Cada canal é dividido por 255, passa por uma correção de gama (a tela não é linear) e entra numa soma ponderada em que o verde pesa mais, porque o olho humano é mais sensível a ele.
// contraste.js — rode com: node contraste.js
function canal(valor) {
const c = valor / 255;
return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
}
function luminancia(hex) {
const n = parseInt(hex.replace('#', ''), 16);
const r = (n >> 16) & 255;
const g = (n >> 8) & 255;
const b = n & 255;
return 0.2126 * canal(r) + 0.7152 * canal(g) + 0.0722 * canal(b);
}
function contraste(corA, corB) {
const a = luminancia(corA);
const b = luminancia(corB);
const clara = Math.max(a, b);
const escura = Math.min(a, b);
return (clara + 0.05) / (escura + 0.05);
}
const pares = [
['#777777', '#ffffff'],
['#767676', '#ffffff'],
['#999999', '#f5f5f5'],
['#595959', '#ffffff'],
['#333333', '#ffffff'],
];
for (const [texto, fundo] of pares) {
console.log(texto, 'em', fundo, '=', contraste(texto, fundo).toFixed(2));
}
A função aceita só hexadecimal de seis dígitos, que é o caso comum. A saída mostra, aproximadamente:
#777777 em #ffffff = 4.48
#767676 em #ffffff = 4.54
#999999 em #f5f5f5 = 2.61
#595959 em #ffffff = 7.00
#333333 em #ffffff = 12.63
O exemplo trabalhado da clínica
Fazendo a conta à mão para o #777777: 0x77 é 119, e 119/255 dá 0,467. Pela correção de gama, isso vira cerca de 0,185. Como os três canais são iguais, a luminância é 0,185. Contra o branco (luminância 1): 1,05 / 0,235 = 4,48. Reprovado.
A data dos posts da clínica usava #999999 dentro do card #F5F5F5: 2,61:1, longe até do mínimo de texto grande. A legenda trocou para #595959, que dá 7:1 no branco e continua acima de 6:1 no card. Visualmente ainda é "um cinza mais leve que o texto principal", e a hierarquia que a designer queria continua lá.
Você não precisa rodar script para tudo: o seletor de cor do DevTools do Chrome mostra a razão de contraste do texto selecionado, e o inspetor de acessibilidade do Firefox também. O script serve para validar a paleta inteira de uma vez, inclusive no CI.
Organize as cores em variáveis
Quarenta hexadecimais espalhados pelo CSS tornam impossível garantir contraste, porque ninguém sabe quais pares se encontram. Com variáveis de papel (fundo, texto, texto suave, link, borda), você testa os pares uma vez e o tema escuro só troca os valores.
- Liste os papéis de cor que o site realmente usa, sem nomear pela cor ("cinza-claro") e sim pela função ("texto-suave").
- Para cada papel de texto, meça contra cada fundo em que ele aparece.
- Defina as variáveis em
:roote use só elas nos componentes. - Crie o tema escuro redefinindo as mesmas variáveis e meça de novo, porque um par que passa no claro não garante nada no escuro.
- Coloque o script de contraste no pipeline para que uma troca de cor que reprova quebre o build.
:root {
--cor-fundo: #ffffff;
--cor-card: #f5f5f5;
--cor-texto: #333333;
--cor-texto-suave: #595959;
--cor-link: #0b57d0;
}
@media (prefers-color-scheme: dark) {
:root {
--cor-fundo: #121212;
--cor-card: #1e1e1e;
--cor-texto: #e8e8e8;
--cor-texto-suave: #b0b0b0;
--cor-link: #8ab4f8;
}
}
body { background: var(--cor-fundo); color: var(--cor-texto); }
.card { background: var(--cor-card); }
.legenda { color: var(--cor-texto-suave); }
a { color: var(--cor-link); text-decoration: underline; }
Rode o script com os valores do tema escuro antes de publicar. E repare no text-decoration: underline: link reconhecido só pela cor falha para quem não distingue azul de preto. Sublinhado ou outro sinal além da cor resolve.
No Windows, o modo de alto contraste ativa a media query forced-colors: active e substitui suas cores pelas do sistema. Se você usa só variáveis e bordas reais, quase tudo continua funcionando. Ícone desenhado com background-image e borda feita com sombra costumam sumir nesse modo.
Erros comuns
- Escolher o cinza pelo olho num monitor bom. O par reprova por pouco, como o
#777777, e ninguém percebe até o primeiro relatório de acessibilidade ou a primeira reclamação de leitor. - Medir o texto só contra o fundo da página. O mesmo cinza dentro de um card, de um banner ou de um botão desabilitado reprova, porque o fundo real é outro.
- Texto sobre foto. Em parte da imagem o contraste passa, em outra some. Sem uma camada escura semitransparente atrás do texto, o resultado depende da foto da semana.
- Tema escuro com as cores invertidas no automático. O azul de link que passava no branco fica ilegível no fundo escuro.
- Esquecer as bordas dos campos. Campo de formulário com borda
#e0e0e0no branco fica abaixo de 3:1 e a pessoa não acha onde digitar. - Hexadecimal solto em cada componente. Trocar a cor da marca vira caça a quarenta ocorrências, e uma delas fica para trás com o contraste errado.
Checklist
- Todo texto comum tem pelo menos 4,5:1 contra o fundo em que realmente aparece.
- Texto grande, ícones que funcionam como rótulo e bordas de campo têm pelo menos 3:1.
- As cores vêm de variáveis com nome de função, não de hexadecimais espalhados.
- O tema escuro foi medido par a par, não só invertido.
- Links se distinguem do texto por algo além da cor.
- A página continua legível com
forced-colorsativo e com zoom de 200%. - Uma verificação automática de contraste roda antes do deploy.
Para ir além da conta, veja por que o cinza claro no texto é tão comum, como testar uma paleta gerada por ferramenta e o caso do anel de foco que some no tema escuro. Agora abra o DevTools no seu site, clique na legenda mais clara que você tem e olhe o número: ela passa de 4,5:1 no fundo onde está de verdade?
Referência: http://www.cultura.ufpa.br/dicas/htm/htm-cor1.htm