Gradientes, bandas e porque o meio parece turvo
Publicado a 21/08/2025 · 18 min de leitura · Ferramentas para programadores
Daniel Okonkwo — Programador front-end e redator de Tecnologia na Allin
Desempenho web · Formatos de ficheiro
Verificado a partir de 6 fontes
Um gradiente CSS interpola os números guardados nas cores, e esses números estão codificados em gama. O sRGB gasta os seus 8 bits onde o olho é sensível em vez de em proporção à luz, por isso fazer a média de dois valores codificados não dá a cor com a quantidade média de luz. Tome vermelho #ff0000 para verde #00ff00. O ponto médio sRGB simples é #808000, com luminância relativa WCAG de 0,1986. Linearize ambos os extremos, faça a média aí e volte a codificar: obtém #bcbc00 com luminância 0,4639 — exatamente a média das luminâncias dos extremos, como deve ser meia-luz. O ponto médio por omissão fica 57,2 % abaixo, e esse défice é a banda escura e turva que se vê no centro de qualquer gradiente de duas cores. Um segundo problema, sem relação, são as bandas. Oito bits por canal dão 256 valores, por isso uma rampa entre duas cores escuras próximas pode conter apenas 28 cores distintas; esticada por 1920 píxeis isso dá uma banda de 68,6 píxeis de largura média, visível do outro lado da sala. O pontilhado esconde-a trocando ruído espacial por resolução tonal. O CSS já permite corrigir o primeiro problema diretamente com `in oklab` ou `in oklch`, e a palavra-chave de direção de matiz decide por que lado da roda a cor viaja.
Interpolar em sRGB faz a média de números codificados em gama: o ponto médio de vermelho para verde é #808000 quando a resposta a meia-luz é #bcbc00 — menos 57,2 % de luz. As bandas são outro problema aritmético: 8 bits dão 256 degraus, e um gradiente escuro pode ter apenas 28. Eis ambos, calculados.
A média é feita nas unidades erradas
Um linear-gradient em CSS faz o óbvio: para cada píxel calcula uma posição t entre 0 e 1 e devolve as cores extremas misturadas segundo t. A subtileza é o que mistura. Um código hexadecimal não é uma medida de luz; é uma palavra de código, e o sRGB atribui essas palavras deliberadamente de forma não linear para gastar os 256 valores disponíveis onde a visão humana os consegue distinguir. O valor guardado é aproximadamente a luz elevada a 1/2,2, e não a luz em si.
A função de transferência sRGB está escrita na norma e é fácil de implementar. Para descodificar, divida o valor de 8 bits por 255 para obter c e devolva c ÷ 12,92 se c for no máximo 0,04045, e ((c + 0,055) ÷ 1,055) elevado a 2,4 caso contrário. Para codificar, inverta: multiplique por 12,92 abaixo de 0,0031308, caso contrário 1,055 vezes o valor elevado a 1/2,4, menos 0,055. Essas duas funções bastam para passar de palavras de código a luz e vice-versa.
Passe um único número e o tamanho do problema já se vê. Metade da luz — linear 0,5 — codifica-se como 0,735357, que é 188 em 8 bits, ou #bcbcbc. A cor a que quase todos chamam cinzento de cinquenta por cento, #808080, descodifica-se como 0,215861. Não é metade de nada. É a palavra de código que está a meio do código, coisa completamente diferente do meio da luz.
De vermelho a verde, medido das duas formas
Tome #ff0000 até #00ff00 e calcule o ponto médio duas vezes. A forma ingénua faz a média dos valores guardados: (255 + 0) ÷ 2 = 127,5 para o vermelho, igual para o verde, 0 para o azul. Isso dá #808000, e passá-lo pelo cálculo de luminância WCAG dá 0,1986. A forma correta descodifica primeiro: o vermelho passa a linear (1, 0, 0) e o verde a (0, 1, 0), a média é (0,5, 0,5, 0), e ao recodificar saem 187,5 em ambos os canais — #bcbc00, com luminância 0,4639.
O segundo número é verificável de forma independente: a luminância relativa de #ff0000 é 0,2126 e a de #00ff00 é 0,7152, e a média delas é 0,4639 — exatamente o que o ponto médio linear mede. Essa é a definição de meio caminho em luz, e o método linear reproduz-na até ao último dígito. O ponto médio sRGB entrega 0,1986, ou seja 57,2 % abaixo. Falta bem mais de metade da luz que deveria estar no centro do gradiente.
O mesmo défice aparece onde quer que os dois extremos estejam afastados no espaço dos códigos. Preto para branco: ponto médio sRGB #808080 com luminância 0,2140, ponto médio linear #bcbcbc com 0,5000, o mesmo défice de 57,2 %. Branco para azul: #8080ff com 0,2708 contra #bcbcff com 0,5361, um défice de 49,5 %. Não é uma esquisitice de cores concretas; é a forma da função de transferência, e aplica-se a todos os gradientes por omissão na web.
De azul a amarelo passa por cinzento — outra falha com outro remédio
É tentador atribuir também ao problema da gama o centro cinzento de um gradiente azul-amarelo, e isso revela-se falso. Calcule ambos os pontos médios para #0000ff até #ffff00. O ponto médio sRGB é #808080 com luminância 0,2140. O ponto médio em luz linear é #bcbcbc com luminância 0,5000. A versão linear é bem mais clara, exatamente como a secção anterior prevê — e continua a ser um cinzento perfeitamente neutro. Medidos em OKLCH, ambos têm um croma de 0,000. Trabalhar em luz linear corrigiu a escuridão e não fez absolutamente nada contra o cinzento.
A razão é que azul e amarelo são quase opostos no cubo RGB, por isso fazer a média dos seus canais cancela a cor sejam quais forem as unidades. Toda a interpolação retangular — sRGB, sRGB linear, até OKLab, que é retangular apesar de percetual — percorre uma reta que passa pelo meio do espaço de cor, e o meio do espaço de cor é neutro. Só uma interpolação polar, que transporta o croma e roda o matiz em vez de fazer a média de canais opostos, contorna pelo exterior.
Uma demonstração mais limpa usa um par que fica dentro do gamut sRGB: #007bff até #ff6b00, um azul e um laranja cujos matizes OKLCH são 257,2° e 45,1°. Em sRGB simples o ponto médio é #807380, um cinzento-malva turvo com croma OKLCH de 0,024 — a cor morreu. Em sRGB linear é #bc73bc, croma 0,131. Em OKLab é #a285a5, croma 0,058. Em OKLCH pelo arco de matiz curto é #d454c9, croma 0,208: um magenta vivo, e o croma é agora aproximadamente a média dos extremos em vez de uma fração. Os mesmos dois pontos, cinco centros diferentes.
Bandas: 256 valores esticados por 2560 píxeis
As bandas nada têm a ver com o espaço de interpolação. É um problema de contagem. Um canal de 8 bits tem 256 valores possíveis, por isso um gradiente nunca pode conter mais cores distintas do que as que os seus extremos abrangem. Se uma rampa for desenhada por mais píxeis do que tem valores distintos, alguns valores têm de se repetir, e cada troço repetido é uma faixa plana.
Rasterizar as rampas píxel a píxel e contar dá os números reais. Um gradiente completo de preto a branco contém 256 cores distintas por mais largo que seja desenhado. Em 320 píxeis a banda média é de 1,3 píxeis e o troço plano mais longo de 2: ninguém os verá. Em 1920 píxeis a média sobe a 7,5 píxeis e o troço mais longo a 8. Em 2560 píxeis é 10,0 e 11 — uma risca visível, na maior amplitude tonal possível.
Os gradientes reais são bem piores, porque os designers raramente cobrem toda a amplitude. Um hero escuro na moda que vai de #1a1a2e a #16213e contém apenas 28 cores distintas. Em 1920 píxeis isso dá uma banda média de 68,6 píxeis, com o troço plano mais longo a chegar a 120 píxeis. Em 2560 píxeis a média é de 91,4 píxeis. Uma superfície subtil de branco sobre branco, de #ffffff a #f5f5f5, contém 11 cores: a 1920 píxeis a banda média mede 174,5 píxeis. Não são artefactos que um visitante talvez note; são riscas mais largas do que um botão.
Daqui sai uma regra utilizável. Se tomar 3 píxeis como a largura a partir da qual uma banda plana se lê como uma aresta e não como tom contínuo, as bandas tornam-se visíveis assim que a largura desenhada excede três vezes o número de níveis distintos. Uma rampa completa de 256 níveis aguenta até 768 píxeis. Uma de 52 níveis aguenta até 156. O hero de 28 níveis aguenta até 84 píxeis, e a superfície de 11 níveis até 33. Quase todos os gradientes decorativos de uma página moderna estão desenhados muito além do seu limite.
O pontilhado, e o que dariam mais bits
O pontilhado é o remédio padrão e funciona trocando um tipo de erro por outro. Em vez de arredondar cada píxel ao valor disponível mais próximo, adiciona-se algum ruído antes de arredondar, de modo que os píxeis junto a uma fronteira se repartem entre os dois valores vizinhos em proporção à sua proximidade. A média sobre qualquer área pequena fica então correta mesmo que nenhum píxel o esteja, e o olho integra a área em vez do píxel. A risca visível dá lugar a um grão ténue.
Na web raramente controla o rasterizador, por isso o pontilhado costuma aplicar-se à mão: sobreponha ao gradiente uma textura de ruído em mosaico e de contraste muito baixo, com uma opacidade de um ou dois por cento, ou gere o gradiente como SVG com uma camada feTurbulence. Ambas as abordagens quebram os troços planos. O truque é manter o ruído abaixo do limiar em que se lê como textura: se vir o grão, passou-se e trocou uma risca por uma superfície suja.
Mais bits removeriam a necessidade por completo. Com 10 bits por canal há 1024 níveis, e a mesma rampa de amplitude total em 1920 píxeis dá uma banda média de 1,88 píxeis — abaixo do limiar visível, logo sem pontilhado. Os ecrãs de gamut alargado e as cadeias HDR oferecem cada vez mais essa profundidade, e as cores CSS escritas com oklch() ou com a função color() sabem exprimi-la. A restrição é toda a cadeia: um valor de 10 bits composto num buffer de 8 bits volta a ser um valor de 8 bits.
As palavras-chave CSS: in oklch, in lab e a direção de matiz
O CSS Color 4 permite nomear o espaço de interpolação diretamente dentro da função de gradiente: linear-gradient(in oklch, #007bff, #ff6b00). Os espaços aceites incluem srgb, srgb-linear, lab, oklab, lch, oklch, hsl, hwb e xyz. Os retangulares — srgb, srgb-linear, lab, oklab, xyz — interpolam as coordenadas componente a componente. Os polares — hsl, hwb, lch, oklch — interpolam um ângulo de matiz, e é aí que vivem as surpresas.
Para um espaço polar pode acrescentar um método de interpolação de matiz: shorter, longer, increasing ou decreasing, cada um seguido da palavra hue. Shorter toma o arco de no máximo 180°, que é o valor por omissão. Longer toma o arco complementar, dando a volta longa. Increasing obriga o ângulo a crescer, decreasing a decrescer. Exemplo resolvido: ao interpolar do matiz 20 ao matiz 320, shorter dá um arco de −60° e o caminho 20, 5, 350, 335, 320, ao passo que longer dá um arco de +300° e o caminho 20, 95, 170, 245, 320 — pelo verde e pelo azul, um gradiente completamente diferente a partir dos mesmos dois pontos.
Há uma segunda coisa a vigiar nos espaços polares. O OKLCH pode descrever cores fora do gamut sRGB, e o caminho polar abauda para fora: o ponto médio OKLCH do azul #0000ff e do amarelo #ffff00 calcula-se em coordenadas que dão um trio RGB fora de alcance e tem de passar por gamut mapping antes de ser mostrado. Os navegadores tratam disso, mas o resultado levará menos croma do que a aritmética pediu, por isso um gradiente brilhante num seletor de cor pode chegar bem mais contido ao ecrã. O par dentro do gamut usado acima evita o problema por completo, e essa é a lição prática: verifique o meio, não apenas os extremos.
Desvanecer para transparente e o alfa pré-multiplicado
O último bug clássico de gradiente é a névoa cinzenta no fim de um desvanecimento para transparente. Acontece quando a interpolação trata cor e alfa como quatro números independentes. Desvaneça branco para `transparent`: como a palavra-chave CSS `transparent` vale rgb(0 0 0 / 0) — preto transparente — o canal de cor é arrastado do branco para o preto ao mesmo tempo que o alfa desce. No ponto médio a cor é cinzento a 50 % e o alfa vale 0,5, por isso sobre uma página branca o píxel desenha-se como #bfbfbf: uma banda suja visível onde deveria haver um desvanecimento limpo.
O alfa pré-multiplicado elimina o artefacto. Antes de interpolar, multiplique cada canal de cor pelo seu próprio alfa; interpole em conjunto os canais pré-multiplicados e o alfa; e depois volte a dividir no fim. Um ponto totalmente transparente não contribui em nada para a cor, porque os seus canais pré-multiplicados são todos zero, logo não pode arrastar a cor visível para lado nenhum. Correr o mesmo desvanecimento de branco para preto transparente em pré-multiplicado dá #ffffff no ponto médio sobre fundo branco em vez de #bfbfbf, e #808080 sobre preto em vez de #404040.
Os gradientes CSS estão especificados para interpolar em espaço pré-multiplicado, por isso os navegadores modernos acertam nisto. A correção manual, que vale a pena conhecer porque sobrevive em código canvas, em SVG, nos editores de imagem e em motores antigos, consiste em nomear o ponto transparente explicitamente na cor de que parte: use rgb(255 255 255 / 0) em vez da palavra-chave `transparent`. Calcule ambos os pontos médios e a equivalência é exata — desvanecer branco para branco transparente dá #ffffff sobre branco e #808080 sobre preto por qualquer dos métodos, pré-multiplicado ou não.
| Espaço de interpolação | CSS | Ponto médio | Croma OKLCH | Resultado |
|---|---|---|---|---|
| sRGB (por omissão) | in srgb | #807380 | 0,024 | Escuro e quase incolor |
| sRGB em luz linear | in srgb-linear | #bc73bc | 0,131 | Brilho correto, matiz apagado |
| OKLab (retangular) | in oklab | #a285a5 | 0,058 | Claridade uniforme, ainda lavado |
| OKLCH, arco curto | in oklch shorter hue | #d454c9 | 0,208 | Magenta vivo, croma preservado |
| OKLCH, arco longo | in oklch longer hue | #00b145 | 0,208 | Verde, fora do gamut e cortado |
Perguntas frequentes
- Porque é que o meu gradiente de duas cores tem uma banda escura ao meio?
- Porque o navegador fez a média de números codificados em gama em vez de luz. O sRGB guarda um valor próximo da luz elevada a 1/2,2, por isso a média aritmética de dois valores guardados corresponde a muito menos luz do que a média das duas luzes. Vermelho #ff0000 para verde #00ff00 torna-o concreto: o ponto médio sRGB simples é #808000 com luminância relativa 0,1986, ao passo que o verdadeiro ponto médio a meia-luz é #bcbc00 com 0,4639 — exatamente a média das luminâncias dos extremos, 0,2126 e 0,7152. O comportamento por omissão fica 57,2 % abaixo da luz que deveria transportar, e esse défice é a banda escura. A correção de uma linha em CSS é nomear um espaço de interpolação melhor: linear-gradient(in oklab, red, lime) ou linear-gradient(in srgb-linear, red, lime). Ambos levantam o centro. Se também quiser que o meio se mantenha colorido em vez de ficar neutro, precisa de um espaço polar — in oklch — porque esse é um problema separado, descrito na resposta seguinte.
- Porque é que de azul a amarelo passa por cinzento mesmo num espaço melhor?
- Porque o cinzento e a escuridão são falhas distintas. Azul e amarelo estão quase opostos, por isso fazer a média dos seus canais cancela a cor sejam quais forem as unidades. Os números confirmam: o ponto médio sRGB de #0000ff e #ffff00 é #808080 com luminância 0,2140, e o ponto médio em luz linear é #bcbcbc com luminância 0,5000. A versão linear é bem mais clara, como se esperava, e ambas têm croma OKLCH de 0,000 — perfeitamente neutro. Até o OKLab, apesar de percetual, dá #6cabc7 com um croma de apenas 0,077, porque o OKLab é retangular e uma reta entre duas cores opostas passa pelo eixo neutro. Só uma interpolação polar o evita, porque roda o matiz e transporta o croma em vez de fazer a média de canais opostos. Use linear-gradient(in oklch, blue, yellow). Note que para este par concreto o ponto médio polar cai fora do gamut sRGB e sofrerá gamut mapping, pelo que obterá menos croma do que o prometido. Se quiser um centro vivo com segurança, acrescente um ponto intermédio explícito no matiz que realmente quer.
- Que largura pode ter um gradiente antes de começar a bandear?
- Depende inteiramente de quão afastados estão os extremos, não do tamanho do elemento. Conte as cores de 8 bits distintas que a rampa pode conter e multiplique por cerca de três: tomando 3 píxeis como a largura a partir da qual uma banda plana se lê como uma aresta, esse produto é a largura em que as bandas aparecem. Uma rampa completa de preto a branco contém 256 cores e aguenta até cerca de 768 píxeis. Uma rampa de #0f172a até um ardósia próximo contém 52 cores e aguenta até 156. Um hero escuro na moda de #1a1a2e a #16213e contém apenas 28 cores e bandeia para lá de 84 píxeis — ou seja, bandeia em todos os layouts em que apareça. Uma superfície quase branca de #ffffff a #f5f5f5 contém 11 cores e cede aos 33 píxeis; desenhada por 1920, a sua banda média mede 174,5 píxeis. Três gestos práticos: alargar a distância tonal entre os pontos para dispor de mais níveis, acrescentar um ruído ténue a um ou dois por cento de opacidade para pontilhar as fronteiras, ou traçar o gradiente na diagonal em vez de a direito, o que alonga a rampa em relação ao elemento e desalinha as arestas das bandas do layout.
- O que faz realmente `longer hue`, e quando o quereria?
- Obriga o matiz a dar a volta longa ao círculo em vez de tomar o arco curto. Numa interpolação polar o navegador tem dois caminhos candidatos entre dois ângulos de matiz e por omissão escolhe o de no máximo 180°. Ao interpolar do matiz 20 ao matiz 320, `shorter hue` dá um arco de −60° e passa por 5, 350 e 335 — um percurso curto pelos vermelhos. `longer hue` dá um arco de +300° e passa por 95, 170 e 245 — verdes, cianos e azuis, ou seja o espetro inteiro. Os mesmos dois pontos, gradiente completamente diferente. Vale a pena recorrer à palavra-chave quando se quer uma varredura de arco-íris a partir de dois extremos sem escrever pontos intermédios, algo comum em indicadores de progresso, rampas de visualização de dados e contornos decorativos. Há mais duas palavras-chave: `increasing hue` obriga o ângulo a crescer e `decreasing hue` a decrescer, e é assim que se controla a direção e não apenas o comprimento. Atenção ao caso limite em que os dois matizes estão exatamente a 180°: shorter e longer descrevem então o mesmo comprimento de arco e é preciso usar increasing ou decreasing para dizer por onde.
- Porque é que o meu desvanecimento para transparente parece sujo na borda?
- Quase sempre porque o ponto transparente é preto transparente. A palavra-chave CSS `transparent` expande para rgb(0 0 0 / 0), por isso um gradiente escrito de branco `to transparent` arrasta o canal de cor do branco para o preto enquanto o alfa desce. Se cor e alfa forem interpolados em separado, o ponto médio é cinzento a 50 % com alfa 0,5, que composto sobre uma página branca dá #bfbfbf — uma névoa cinzenta visível. Interpolar em espaço pré-multiplicado corrige: multiplique cada cor pelo seu alfa antes de misturar, misture e volte a dividir; um ponto totalmente transparente deixa de contribuir para a cor porque os seus canais pré-multiplicados são zero. O mesmo ponto médio compõe-se então como #ffffff sobre branco e #808080 sobre preto. Os gradientes CSS estão especificados em pré-multiplicado, pelo que os navegadores atuais acertam aqui; o bug sobrevive em código canvas, em SVG, em editores de imagem e em motores antigos. A correção portátil é escrever o ponto na cor de que parte — rgb(255 255 255 / 0) em vez de `transparent` — o que dá resultados idênticos com qualquer dos métodos e não custa nada.
Artigos que podem interessar-lhe
Todos os guias →Ferramentas relacionadas
Fontes
- W3C — CSS Color Module Level 4 — interpolation, colour spaces, hue interpolation methods and premultiplied alpha
- W3C — CSS Images Module Level 4 — gradient interpolation and colour stop resolution
- IEC — IEC 61966-2-1:1999 — Default RGB colour space (sRGB), the piecewise transfer function
- Charles Poynton — Gamma FAQ — why encoding is nonlinear and what breaks when you average encoded values
- Björn Ottosson — A perceptual color space for image processing (OKLab), 2020
- W3C — Web Content Accessibility Guidelines (WCAG) 2.2 — the relative luminance definition used for the midpoint measurements
Detetaste um erro neste artigo?