Pular para o conteúdo
Cursos

DevClub

LógicaFront-endBack-endMobile

IA Club

IA na prática
Estudar programaçãoEstudar IA
LiçãoIntermediáriocódigo testado

Cores no CSS: hex, rgb, hsl e oklch na prática

Como escrever cor em CSS, o que muda entre hsl e oklch quando você clareia um tom e como montar uma paleta que não desbota no meio da escala.

Rodolfo Mori15 min de leitura

Cor no CSS é sempre a mesma cor escrita de jeitos diferentes: #1b7f9d, rgb(27 127 157) e hsl(194 71% 36%) pintam exatamente o mesmo pixel. O que muda entre as notações é o quanto você consegue prever o resultado quando mexe num número — e é nisso que oklch() deixa as outras para trás.

Todos os exemplos saem da mesma tela: o painel operacional do Metrô de Guaraciaba, com doze linhas, cada uma com a sua cor, e os avisos de status (operando, lentidão, interrompida). Nesse tipo de painel a cor não é enfeite: ela carrega informação, e precisa carregar a mesma quantidade de informação nas doze linhas.

Para conferir cada afirmação daqui eu rodei código no Node 24.16.0, com a biblioteca colorjs.io 0.6.0, o jsdom (que normaliza cor do mesmo jeito que o navegador) e o lightningcss (que resolve color-mix() na compilação). Toda saída abaixo do código é a saída real desses programas.

Hexadecimal, rgb() e a sintaxe moderna sem vírgula

Um valor de cor no CSS é sempre uma combinação de vermelho, verde e azul, mais uma opacidade opcional. O hexadecimal é essa combinação escrita em base 16, dois dígitos por canal: #1b7f9d é 1b de vermelho, 7f de verde e 9d de azul. Quando os dois dígitos de cada canal são iguais, dá para abreviar em três: #0af é o mesmo que #00aaff.

css
.linha-8 { color: #1b7f9d; }        /* hexadecimal */
.linha-8 { color: rgb(27 127 157); } /* sintaxe moderna, sem vírgula */
.linha-8 { color: hsl(194 71% 36%); }
.aviso   { color: teal; }            /* uma das 148 cores com nome */

Repare que a sintaxe moderna separa os valores por espaço, não por vírgula. As duas formas funcionam, mas a moderna é a que aceita a barra do canal alfa (rgb(27 127 157 / 40%)) e a que as funções novas usam. Para provar que tudo isso é a mesma cor, dá para pedir o valor calculado — que é o que o navegador guarda depois de interpretar o que você escreveu.

js
import { JSDOM } from 'jsdom';

const escritas = [
  '#1b7f9d',
  '#1B7F9D',
  'rgb(27, 127, 157)',
  'rgb(27 127 157)',
  'hsl(194 71% 36%)',
  '#0af',
  '#00aaff',
  'teal',
  'rebeccapurple',
];

const dom = new JSDOM(
  escritas.map((c, i) => `<span id="c${i}" style="color: ${c}"></span>`).join('\n'),
);
const { document, getComputedStyle } = dom.window;

escritas.forEach((escrita, i) => {
  const el = document.getElementById(`c${i}`);
  console.log(escrita.padEnd(20), '->', getComputedStyle(el).color);
});
#1b7f9d -> rgb(27, 127, 157) #1B7F9D -> rgb(27, 127, 157) rgb(27, 127, 157) -> rgb(27, 127, 157) rgb(27 127 157) -> rgb(27, 127, 157) hsl(194 71% 36%) -> rgb(27, 127, 157) #0af -> rgb(0, 170, 255) #00aaff -> rgb(0, 170, 255) teal -> rgb(0, 128, 128) rebeccapurple -> rgb(102, 51, 153)

Cinco notações diferentes, um único rgb(27, 127, 157). Maiúscula ou minúscula no hex não muda nada. Nome de cor também é só um apelido para um trio de números — e rebeccapurple existe mesmo, é uma homenagem gravada na especificação.

O problema do hex aparece quando você precisa de uma variação dele. Para clarear #1b7f9d em 20% você não tem por onde começar: os três números não falam de claridade, falam de quanto de cada luz acender.

hsl(): matiz, saturação e luminosidade em números que dá para ler

hsl() reorganiza a mesma informação em três eixos que uma pessoa consegue raciocinar. Matiz (hue) é o ângulo na roda de cores, de 0 a 360: 0 é vermelho, 120 é verde, 240 é azul. Saturação é quanto de cor tem ali, de 0% (cinza) a 100%. Luminosidade é a posição entre o preto (0%) e o branco (100%).

css
.linha-8         { color: hsl(194 71% 36%); }  /* a cor institucional */
.linha-8--claro  { color: hsl(194 71% 90%); }  /* só o L subiu */
.linha-8--escuro { color: hsl(194 71% 20%); }  /* só o L desceu */
.linha-8--apagado{ color: hsl(194 12% 36%); }  /* só o S desceu */
.linha-1         { color: hsl(14 71% 36%); }   /* só o matiz girou */

Cada linha ali muda um eixo por vez, e é isso que faz o hsl() ser tão mais confortável do que o hex. Convertendo de volta:

js
import Color from 'colorjs.io';

const hex = (s) => new Color(s).to('srgb').toString({ format: 'hex' });

const variacoes = [
  'hsl(194 71% 36%)',
  'hsl(194 71% 90%)',
  'hsl(194 71% 20%)',
  'hsl(194 12% 36%)',
  'hsl(14 71% 36%)',
];

for (const v of variacoes) console.log(v.padEnd(20), '->', hex(v));
hsl(194 71% 36%) -> #1b7f9d hsl(194 71% 90%) -> #d3eff8 hsl(194 71% 20%) -> #0f4657 hsl(194 12% 36%) -> #516267 hsl(14 71% 36%) -> #9d391b

Cinco hexadecimais que você jamais adivinharia de cabeça saíram de cinco linhas legíveis. É por isso que hsl() dominou os pré-processadores e os primeiros design systems.

O defeito do hsl: mesmo L, brilhos completamente diferentes

Aqui começa o problema que este artigo existe para mostrar. O L do hsl() não é o brilho que o olho enxerga. Ele é uma conta geométrica sobre os canais RGB, e essa conta ignora o fato de que o olho humano é muito mais sensível ao verde do que ao azul.

Consequência prática: se você pintar as doze linhas do metrô com hsl(h 80% 50%), girando só o matiz, vai jurar que escolheu doze cores igualmente fortes. Não escolheu. Dá para medir isso com a luminância relativa da WCAG — o mesmo número que a fórmula de contraste usa.

js
import Color from 'colorjs.io';

const matizes = [0, 30, 60, 90, 120, 150, 180, 210, 240, 270, 300, 330];

// Luminância relativa da WCAG: o Y do XYZ-D65, que é o que a fórmula de
// contraste usa. Contraste contra o texto branco = (1 + 0.05) / (Y + 0.05).
const brilho = (cor) => new Color(cor).luminance;
const contrasteNoBranco = (cor) => 1.05 / (brilho(cor) + 0.05);

console.log('linha  matiz | hsl(h 80% 50%)         | oklch(0.65 0.10 h)');
console.log('             | hex      Y      contr. | hex      Y      contr.');
matizes.forEach((h, i) => {
  const a = `hsl(${h} 80% 50%)`;
  const b = `oklch(0.65 0.10 ${h})`;
  console.log(
    String(i + 1).padStart(5),
    String(h).padStart(5),
    '|',
    new Color(a).to('srgb').toString({ format: 'hex' }),
    brilho(a).toFixed(3).padStart(6),
    (contrasteNoBranco(a).toFixed(2) + ':1').padStart(7),
    '|',
    new Color(b).to('srgb').toString({ format: 'hex' }),
    brilho(b).toFixed(3).padStart(6),
    (contrasteNoBranco(b).toFixed(2) + ':1').padStart(7),
  );
});

const faixa = (escreve) => {
  const ys = matizes.map((h) => brilho(escreve(h)));
  const cs = matizes.map((h) => contrasteNoBranco(escreve(h)));
  return `Y ${Math.min(...ys).toFixed(2)} – ${Math.max(...ys).toFixed(2)}` +
    `  contraste ${Math.min(...cs).toFixed(2)} – ${Math.max(...cs).toFixed(2)}`;
};
console.log('');
console.log('hsl   ', faixa((h) => `hsl(${h} 80% 50%)`));
console.log('oklch ', faixa((h) => `oklch(0.65 0.10 ${h})`));
linha matiz | hsl(h 80% 50%) | oklch(0.65 0.10 h) | hex Y contr. | hex Y contr. 1 0 | #e61919 0.175 4.66:1 | #c1758d 0.259 3.40:1 2 30 | #e68019 0.321 2.83:1 | #c5776a 0.261 3.38:1 3 60 | #e6e619 0.731 1.34:1 | #bc804d 0.266 3.32:1 4 90 | #80e619 0.609 1.59:1 | #a78c41 0.274 3.24:1 5 120 | #19e619 0.566 1.70:1 | #87984f 0.283 3.16:1 6 150 | #19e680 0.581 1.66:1 | #60a06e 0.289 3.10:1 7 180 | #19e6e6 0.622 1.56:1 | #38a391 0.291 3.08:1 8 210 | #1980e6 0.212 4.01:1 | #2f9fb1 0.287 3.11:1 9 240 | #1919e6 0.066 9.04:1 | #5197c6 0.279 3.19:1 10 270 | #8019e6 0.110 6.58:1 | #778ccd 0.271 3.28:1 11 300 | #e619e6 0.231 3.73:1 | #9981c3 0.263 3.35:1 12 330 | #e61980 0.190 4.37:1 | #b278ac 0.259 3.39:1

hsl Y 0.07 – 0.73 contraste 1.34 – 9.04 oklch Y 0.26 – 0.29 contraste 3.08 – 3.40

Leia a coluna Y do lado do hsl. A linha 3, amarela, tem brilho 0.731. A linha 9, azul, tem 0.066. As duas foram escritas com o mesmo 50% de luminosidade, e uma reflete onze vezes mais luz que a outra.

Agora leia o contraste. Se você puser texto branco em cima do crachá de cada linha, o azul dá 9.04:1 (excelente) e o amarelo dá 1.34:1 — praticamente invisível. Ninguém mexeu no L. O L mentiu.

Do lado do oklch, as mesmas doze voltas na roda produzem Y entre 0.26 e 0.29, e contraste entre 3.08:1 e 3.40:1. É uma faixa que cabe numa decisão de design; a do hsl não cabe em nada.

oklch(): a luminosidade que combina com o que o olho vê

oklch() tem três eixos com nomes parecidos com os do hsl(), mas construídos em cima de um espaço de cor calibrado com testes de percepção humana. A ordem é L, C, H:

eixo o que é faixa o que acontece se você mexer
L luminosidade percebida 0 a 1 (ou 0% a 100%) a cor clareia ou escurece de verdade, em qualquer matiz
C croma, a “força” da cor 0 até 0.322 no sRGB de cinza até o mais saturado que a tela alcança
H matiz, em graus 0 a 360 gira na roda, sem mexer na luminosidade percebida

Um detalhe que economiza horas de confusão: o H do oklch() não é o mesmo número que o H do hsl(). Os dois medem ângulo em graus, mas em rodas diferentes, então a mesma cor troca de endereço quando você muda de modelo.

js
import Color from 'colorjs.io';

const azul = new Color('#1b7f9d'); // a cor institucional da linha 8

console.log('mesma cor:', azul.toString({ format: 'hex' }));
console.log('  matiz em hsl  ', azul.to('hsl').coords[0].toFixed(1) + '°');
console.log('  matiz em oklch', azul.to('oklch').coords[2].toFixed(1) + '°');
mesma cor: #1b7f9d matiz em hsl 193.8° matiz em oklch 223.7°

Trinta graus de diferença para o mesmo pixel. É por isso que o azul da linha 8 aparece como 194 enquanto o artigo está falando de hsl() e como 224 depois que ele passa para oklch(): é a mesma tinta, medida por duas réguas. Copiar o número de matiz de um modelo para o outro é o erro que faz a paleta inteira girar de cor.

O croma não tem um teto fixo como a saturação do hsl(), e isso incomoda no começo. Ele é aberto de propósito: o máximo possível depende do L, do H e do que a tela consegue mostrar — o campeão do sRGB é o magenta #ff00ff, com 0.322. Na prática, 0.02 é quase cinza, 0.10 é uma cor de interface confortável e acima de 0.20 você já está batendo no teto do monitor comum.

css
/* a grade de medição: doze matizes de 30 em 30 graus, mesmo L e mesmo C.
   Quatro delas, com o hex que a tabela acima mediu: */
:root {
  --linha-1:  oklch(0.65 0.10 0);    /* #c1758d */
  --linha-3:  oklch(0.65 0.10 60);   /* #bc804d */
  --linha-8:  oklch(0.65 0.10 210);  /* #2f9fb1 */
  --linha-12: oklch(0.65 0.10 330);  /* #b278ac */
}

Repare que as quatro saem discretas, quase pastel. Não é defeito: é o preço de igualar o brilho. Um amarelo berrante e um azul-marinho nunca vão ter o mesmo peso visual, e o oklch() mostra isso na cara em vez de esconder. Se você quiser crachás mais vibrantes, suba o C até onde o sRGB aguenta — a última seção mede exatamente onde é esse limite.

Se você tentar o mesmo com hsl(), o “degrau 500” da sua paleta vira uma coincidência diferente em cada matiz:

js
import Color from 'colorjs.io';

const linhas = [
  { nome: 'linha 3', hsl: 60, oklch: 60 },
  { nome: 'linha 8', hsl: 210, oklch: 210 },
  { nome: 'linha 12', hsl: 330, oklch: 330 },
];
const degraus = [
  { nome: 100, hslL: 88, okL: 0.92 },
  { nome: 300, hslL: 70, okL: 0.79 },
  { nome: 500, hslL: 52, okL: 0.65 },
  { nome: 700, hslL: 34, okL: 0.5 },
  { nome: 900, hslL: 16, okL: 0.35 },
];

const y = (c) => new Color(c).luminance;

for (const modelo of ['hsl', 'oklch']) {
  console.log(modelo.toUpperCase());
  for (const d of degraus) {
    const ys = linhas.map((l) =>
      modelo === 'hsl'
        ? y(`hsl(${l.hsl} 70% ${d.hslL}%)`)
        : y(`oklch(${d.okL} 0.10 ${l.oklch})`),
    );
    console.log(
      '  degrau',
      String(d.nome).padStart(3),
      ' Y:',
      ys.map((v) => v.toFixed(3)).join('  '),
      ' diferença entre a mais clara e a mais escura:',
      (Math.max(...ys) - Math.min(...ys)).toFixed(3),
    );
  }
  console.log('');
}
HSL degrau 100 Y: 0.897 0.729 0.677 diferença entre a mais clara e a mais escura: 0.220 degrau 300 Y: 0.764 0.422 0.351 diferença entre a mais clara e a mais escura: 0.413 degrau 500 Y: 0.654 0.223 0.187 diferença entre a mais clara e a mais escura: 0.468 degrau 700 Y: 0.273 0.091 0.077 diferença entre a mais clara e a mais escura: 0.196 degrau 900 Y: 0.056 0.021 0.017 diferença entre a mais clara e a mais escura: 0.039

OKLCH degrau 100 Y: 0.763 0.804 0.749 diferença entre a mais clara e a mais escura: 0.056 degrau 300 Y: 0.481 0.512 0.471 diferença entre a mais clara e a mais escura: 0.041 degrau 500 Y: 0.266 0.287 0.259 diferença entre a mais clara e a mais escura: 0.028 degrau 700 Y: 0.120 0.132 0.116 diferença entre a mais clara e a mais escura: 0.016 degrau 900 Y: 0.040 0.046 0.038 diferença entre a mais clara e a mais escura: 0.008

No degrau 500, o amarelo e o rosa do hsl estão a 0.468 de distância de brilho. Em oklch, 0.028 — quase dezessete vezes menos. Um “500” em oklch é um nível; um “500” em hsl é só um número que você escreveu.

Alfa, transparent e o currentColor que herda

Toda notação em forma de função — rgb(), hsl(), oklch() — aceita um quarto valor depois de uma barra: a opacidade. 0 é invisível, 1 é opaco, e a palavra transparent é apenas um apelido para preto com alfa zero. (No hexadecimal não existe barra: o alfa entra como um par extra de dígitos no fim, #1b7f9d80.)

css
.faixa-linha-8 {
  color: oklch(0.53 0.098 224);          /* a cor da linha 8 */
  background: oklch(0.53 0.098 224 / 15%); /* a mesma, quase apagada */
  border: 2px solid currentColor;         /* pega o color acima */
  box-shadow: 0 2px 8px rgb(0 0 0 / 0.12);
}

Cuidado com uma armadilha: cor com alfa não é uma cor, é uma instrução de mistura. O resultado depende do que estiver atrás. A mesma linha do painel dá dois hexadecimais diferentes no modo claro e no modo escuro:

js
import Color from 'colorjs.io';

// Composição alfa: resultado = frente * alfa + fundo * (1 - alfa), em sRGB.
const sobre = (frente, fundo, alfa) => {
  const f = new Color(frente).to('srgb').coords;
  const b = new Color(fundo).to('srgb').coords;
  return new Color('srgb', f.map((v, i) => v * alfa + b[i] * (1 - alfa)))
    .toString({ format: 'hex' });
};

const marca = '#077796';
for (const alfa of [0.08, 0.15, 0.3]) {
  console.log(
    'alfa', alfa.toFixed(2),
    ' sobre o papel branco:', sobre(marca, '#ffffff', alfa),
    ' sobre o fundo do modo escuro:', sobre(marca, '#151a1d', alfa),
  );
}
alfa 0.08 sobre o papel branco: #ebf4f7 sobre o fundo do modo escuro: #142127 alfa 0.15 sobre o papel branco: #daebef sobre o fundo do modo escuro: #13282f alfa 0.30 sobre o papel branco: #b5d6e0 sobre o fundo do modo escuro: #113641

Com 15% de alfa, o mesmo azul vira #daebef no claro e #13282f no escuro. É exatamente o comportamento que você quer numa faixa de fundo, e exatamente o que você não quer num texto: se o fundo mudar, o contraste do texto muda junto, sem aviso.

currentColor é outra história: ele não mistura nada, só copia o valor de color do próprio elemento. E como color é uma propriedade herdada, o valor desce a árvore inteira sozinho.

js
import { JSDOM } from 'jsdom';

const dom = new JSDOM(`
<div id="cartao" style="color: #077796">
  <strong id="titulo">Linha 8 — Universidade</strong>
  <svg id="icone"><path/></svg>
</div>
<div id="alerta" style="color: #9d391b">
  <strong id="titulo2">Linha 3 — Feira</strong>
</div>
`);
const { document, getComputedStyle } = dom.window;
for (const id of ['cartao', 'titulo', 'icone', 'alerta', 'titulo2']) {
  console.log(id.padEnd(8), '->', getComputedStyle(document.getElementById(id)).color);
}
cartao -> rgb(7, 119, 150) titulo -> rgb(7, 119, 150) icone -> rgb(7, 119, 150) alerta -> rgb(157, 57, 27) titulo2 -> rgb(157, 57, 27)

O ícone SVG e o título pegaram a cor sem que ninguém escrevesse color neles. Um componente de cartão que usa currentColor na borda, no ícone e no traço funciona nas doze linhas trocando uma declaração. Se a mecânica da herança ainda parece mágica, vale revisar o que o filho recebe do pai no CSS.

Uma escala de 50 a 900 girando só o L

Com oklch(), uma paleta de interface vira uma escada: fixe o matiz, fixe (mais ou menos) o croma e desça o L. O croma precisa afinar nas pontas, porque perto do branco e do preto a tela simplesmente não tem cor forte para dar.

js
import Color from 'colorjs.io';

const H = 224; // o azul-petróleo institucional do metrô
const degraus = [
  [50, 0.97, 0.018],
  [100, 0.93, 0.045],
  [200, 0.87, 0.075],
  [300, 0.79, 0.1],
  [400, 0.7, 0.115],
  [500, 0.62, 0.115],
  [600, 0.53, 0.098],
  [700, 0.44, 0.08],
  [800, 0.35, 0.064],
  [900, 0.27, 0.049],
];

const y = (c) => new Color(c).luminance;
const contraste = (a, b) => {
  const [alto, baixo] = [y(a), y(b)].sort((m, n) => n - m);
  return (alto + 0.05) / (baixo + 0.05);
};

const cor50 = `oklch(${degraus[0][1]} ${degraus[0][2]} ${H})`;

console.log('degrau  oklch                    hex      cabe no sRGB  contraste sobre o 50');
for (const [nome, L, C] of degraus) {
  const cor = `oklch(${L} ${C} ${H})`;
  console.log(
    String(nome).padStart(5),
    ' ',
    cor.padEnd(24),
    new Color(cor).to('srgb').toString({ format: 'hex' }),
    String(new Color(cor).inGamut('srgb')).padStart(9),
    (contraste(cor, cor50).toFixed(2) + ':1').padStart(15),
  );
}
degrau oklch hex cabe no sRGB contraste sobre o 50 50 oklch(0.97 0.018 224) #e9f8fe true 1.00:1 100 oklch(0.93 0.045 224) #c9effe true 1.12:1 200 oklch(0.87 0.075 224) #9ee0f9 true 1.34:1 300 oklch(0.79 0.1 224) #6cc9ea true 1.74:1 400 oklch(0.7 0.115 224) #38add3 true 2.39:1 500 oklch(0.62 0.115 224) #0994b9 true 3.25:1 600 oklch(0.53 0.098 224) #077796 true 4.72:1 700 oklch(0.44 0.08 224) #085b73 true 6.98:1 800 oklch(0.35 0.064 224) #034153 true 10.23:1 900 oklch(0.27 0.049 224) #022b38 true 13.72:1

Essa tabela é o contrato da paleta, e ela responde perguntas de design sem discussão. Texto do degrau 600 sobre o fundo do degrau 50 dá 4.72:1 — passa no mínimo de 4.5:1 da WCAG para texto normal. O 500 dá 3.25:1 e não passa: serve para borda, para ícone grande, para faixa, nunca para um parágrafo.

Na folha de estilo, a escada vira variáveis CSS e o resto do projeto para de escrever cor na mão:

css
:root {
  --azul-50:  oklch(0.97 0.018 224);
  --azul-100: oklch(0.93 0.045 224);
  --azul-500: oklch(0.62 0.115 224);
  --azul-600: oklch(0.53 0.098 224);
  --azul-900: oklch(0.27 0.049 224);
}

.painel        { background: var(--azul-50); color: var(--azul-900); }
.painel__aviso { background: var(--azul-600); color: white; }
.painel__borda { border-block-end: 1px solid var(--azul-100); }

color-mix(): derivar hover, borda e estado desabilitado

Ter a escada resolve os tons principais. Os estados (:hover, :disabled, a borda mais fraquinha) você deriva com color-mix(), que mistura duas cores num espaço que você escolhe.

css
.aviso {
  background: oklch(0.53 0.098 224);
}
.aviso:hover {
  background: color-mix(in oklch, oklch(0.53 0.098 224), black 12%);
}
.aviso:disabled {
  background: color-mix(in oklch, oklch(0.53 0.098 224), white 55%);
}
.aviso--contorno {
  border-color: color-mix(in oklch, oklch(0.53 0.098 224), transparent 70%);
}
.mistura-srgb {
  background: color-mix(in srgb, oklch(0.53 0.098 224), oklch(0.7 0.115 60) 50%);
}
.mistura-oklch {
  background: color-mix(in oklch, oklch(0.53 0.098 224), oklch(0.7 0.115 60) 50%);
}

O compilador consegue resolver essas contas na hora do build, o que é uma maneira confortável de ver o resultado sem abrir o navegador. Com o bloco acima salvo como painel.css, ao lado do script:

js
import { readFileSync } from 'node:fs';
import { transform } from 'lightningcss';

const { code } = transform({
  filename: 'painel.css',
  code: readFileSync('painel.css'),
  minify: false,
  targets: { chrome: 100 << 16 },
});
console.log(code.toString());
.aviso { background: #077796; background: lab(45.8503% -20.0692 -24.8337); }

.aviso:hover { background: #006480; background: lab(38.4504% -19.4991 -24.7206); }

.aviso:disabled { background: #9cc1cf; background: lab(75.6819% -10.1915 -11.534); }

.aviso–contorno { border-color: #0777964d; border-color: lab(45.8503% -20.0692 -24.8337 / .3); }

.mistura-srgb { background: #6d8273; }

.mistura-oklch { background: #5e9558; background: lab(56.6257% -28.409 26.1834); }

Três coisas para reparar. A primeira: o compilador escreveu a mesma propriedade duas vezes, um hex e um lab(), para navegadores antigos aproveitarem o hex. A segunda: misturar com transparent produziu #0777964d, um hex de oito dígitos — os dois últimos são o alfa, 4d em base 16 é 77, ou seja, 30%.

A terceira é a mais importante. As duas últimas regras misturaram o azul da linha 8 com o amarelo da linha 3, meio a meio, mudando só o espaço da mistura. Em srgb saiu #6d8273, um cinza-esverdeado sujo; em oklch saiu #5e9558, um verde de verdade. Medindo o croma das duas:

js
import Color from 'colorjs.io';
const croma = (hex) => new Color(hex).to('oklch').coords[1].toFixed(3);
console.log('color-mix(in srgb,  ...) = #6d8273  croma =', croma('#6d8273'));
console.log('color-mix(in oklch, ...) = #5e9558  croma =', croma('#5e9558'));
color-mix(in srgb, ...) = #6d8273 croma = 0.033 color-mix(in oklch, ...) = #5e9558 croma = 0.107

A mistura em srgb perdeu dois terços da cor pelo caminho. É o mesmo motivo do degradê que fica cinza no meio: em srgb o caminho entre duas cores passa por perto do eixo neutro. Escreva in oklch (ou in oklab) e o problema desaparece.

Gamut: o que acontece quando a cor não cabe no sRGB

O croma do oklch() não tem teto, mas a sua tela tem. Quando você pede uma cor que o monitor não consegue mostrar, o navegador precisa escolher a mais próxima que ele consegue — e essa escolha mexe justamente no L que você tinha ajustado com cuidado.

js
import Color from 'colorjs.io';

console.log('croma  sRGB?  P3?    hex depois do corte   L real depois do corte');
for (const C of [0.10, 0.15, 0.20, 0.25, 0.30]) {
  const cor = new Color(`oklch(0.65 ${C.toFixed(2)} 150)`);
  const cortada = cor.clone().to('srgb');
  cortada.coords = cortada.coords.map((v) => Math.min(1, Math.max(0, v)));
  console.log(
    C.toFixed(2).padStart(5),
    String(cor.inGamut('srgb')).padStart(6),
    String(cor.inGamut('p3')).padStart(6),
    '  ' + cortada.toString({ format: 'hex' }).padEnd(20),
    cortada.to('oklch').coords[0].toFixed(3),
  );
}
croma sRGB? P3? hex depois do corte L real depois do corte 0.10 true true #60a06e 0.650 0.15 true true #3aa85b 0.650 0.20 false true #00af44 0.656 0.25 false false #00b522 0.670 0.30 false false #00ba00 0.684

Até croma 0.15 o verde da linha 6 cabe no sRGB e o L sai exatamente 0.650. A partir de 0.20 ele estoura: o corte zera o canal vermelho e o L real sobe para 0.656, depois 0.670, depois 0.684. Você pediu mais cor e recebeu mais brilho — e lá se foi a régua que você tinha montado.

Se você quer aproveitar a tela larga sem quebrar a comum, declare a versão segura primeiro e a versão vibrante dentro de uma media query de gamut:

css
:root {
  --linha-6: oklch(0.65 0.15 150);
}

@media (color-gamut: p3) {
  :root {
    --linha-6: oklch(0.65 0.20 150);
  }
}

O erro mais comum: a vírgula que derruba a declaração inteira

Quando uma cor vem errada no CSS, quase nunca é a cor que está errada — é a sintaxe. E o CSS não avisa: uma declaração inválida é descartada em silêncio, e o elemento fica com o valor anterior. É por isso que tanta gente vê vermelho onde queria azul.

Os dois deslizes que mais aparecem: esquecer o % da luminosidade num hsl() escrito com vírgula, e misturar a vírgula antiga com a barra moderna no rgb().

js
import { JSDOM } from 'jsdom';

const dom = new JSDOM(`
<span id="a" style="color: red; color: hsl(194, 71%, 36)"></span>
<span id="b" style="color: red; color: hsl(194, 71%, 36%)"></span>
<span id="c" style="color: red; color: rgb(7, 119, 150 / 40%)"></span>
<span id="d" style="color: red; color: rgb(7 119 150 / 40%)"></span>
<span id="e" style="color: red; color: hsl(194 71 36)"></span>
`);
const { document, getComputedStyle } = dom.window;
for (const id of ['a', 'b', 'c', 'd', 'e']) {
  const el = document.getElementById(id);
  console.log(
    el.getAttribute('style').split('; ')[1].padEnd(34),
    '->',
    getComputedStyle(el).color,
  );
}
color: hsl(194, 71%, 36) -> rgb(255, 0, 0) color: hsl(194, 71%, 36%) -> rgb(27, 127, 157) color: rgb(7, 119, 150 / 40%) -> rgb(255, 0, 0) color: rgb(7 119 150 / 40%) -> rgba(7, 119, 150, 0.4) color: hsl(194 71 36) -> rgb(27, 127, 157)

As linhas a e c continuaram vermelhas. Não houve erro no console, não houve aviso: o navegador leu hsl(194, 71%, 36), não reconheceu, jogou fora e ficou com o color: red de antes.

A regra para não cair nisso é simples: ou tudo com vírgula, ou tudo com espaço, e as duas gramáticas não se misturam. A barra do alfa só existe na forma com espaço. Repare na linha e: ela também escreve 36 sem % e mesmo assim funciona, porque na forma com espaço o hsl() aceita número puro na saturação e na luminosidade. Na forma com vírgula, que é a antiga, o % é obrigatório — foi exatamente o que derrubou a linha a. Quando estiver na dúvida, escreva o %: ele é válido nas duas. No DevTools, uma declaração descartada aparece riscada — quando a sua cor “não funciona”, olhe primeiro se a linha está riscada, e só depois desconfie de especificidade.

O que vem depois

O caminho prático é este: escolha os matizes em oklch(), monte a escada de L uma vez, guarde em custom properties e derive os estados com color-mix(). Hex fica para o arquivo da marca; hsl() fica para ler código antigo.

A próxima peça é aplicar essa paleta em superfície: fundo, cantos arredondados e sombra, em background, border-radius e box-shadow. E se você chegou aqui sem ter visto como funcionam rem, % e ch, volte uma casa em unidades no CSS — cor e unidade são as duas metades do mesmo assunto. O mapa completo está na trilha de CSS.

Prefere aprender em vídeo?

Tem uma aula sobre este assunto no nosso canal.

Ver todos os vídeos do canal
  • css
  • cores
  • oklch
  • hsl
  • paleta
  • contraste

Perguntas frequentes

Dá para usar oklch() hoje sem escrever uma cor de reserva?
Na prática, sim. A função está nas versões estáveis de Chrome, Edge, Safari e Firefox desde 2023, e um navegador que não entende a declaração simplesmente a descarta. Se o seu projeto ainda atende um parque antigo de máquinas, escreva a mesma propriedade duas vezes — a versão em hex primeiro, a versão em oklch logo abaixo.
Então hexadecimal virou coisa do passado?
Não. Hex continua sendo o formato em que as ferramentas de design, os exportadores de logo e os manuais de marca falam. Ele é ótimo para registrar uma cor final e péssimo para derivar outra a partir dela. Guarde o hex no arquivo da marca e trabalhe em oklch dentro do CSS.
Qual a diferença entre oklch() e oklab()?
É o mesmo espaço de cor descrito por dois sistemas de coordenadas. oklab() usa dois eixos cartesianos (a e b) e oklch() usa um raio (croma) e um ângulo (matiz). Para trabalho manual, oklch() é mais fácil, porque "girar o matiz" é somar graus a um número só.
Como escolher os matizes de doze linhas sem elas se confundirem?
Distribuir por igual na roda não basta, porque verde e ciano vizinhos continuam parecidos. Afaste os matizes que ficam lado a lado no mapa, alterne o L entre uma linha e a seguinte, e nunca deixe a cor ser a única pista — o número da linha e o nome precisam estar escritos.

Dúvidas e comentários

Travou em algum passo? Pergunte aqui — a equipe e outros alunos respondem.

Todo o código deste artigo foi executado em Node 24.16.0, com colorjs.io 0.6.0, jsdom 30.0.1 e lightningcss 1.33.0, e as saídas exibidas são as reais — como produzimos este conteúdo.

Fontes consultadas

  1. MDN — Tipo de dado <color> — developer.mozilla.org
  2. MDN — oklch() — developer.mozilla.org
  3. CSS Color Module Level 4 — w3.org
  4. WCAG 2.2 — Understanding Contrast (Minimum) — w3.org
  5. Björn Ottosson — Oklab, a perceptual color space — bottosson.github.io

Continue por aqui