Tutoriais

Manipulação de Cores em JavaScript: Bibliotecas e Técnicas

12 min de leitura

A cor em aplicações web não é um valor estático que você cola de um arquivo de design e deixa de lado. As cores ficam mais claras ao passar o mouse, mais escuras quando pressionadas, se ajustam aos requisitos de contraste de acessibilidade, animam entre estados e se adaptam a temas selecionados pelo usuário. Tudo isso exige manipulação programática de cores — a capacidade de analisar, transformar e gerar valores de cor em JavaScript em tempo de execução.

Este guia cobre como o JavaScript lida com cores nativamente, a matemática por trás das transformações comuns, uma comparação das três bibliotecas mais usadas e como construir um utilitário de cores mínimo quando você quer zero dependências.


Analisando Hex, RGB e HSL em JavaScript

O JavaScript não tem um tipo de cor nativo. As cores chegam como strings — "#FF5733", "rgb(255, 87, 51)", "hsl(11, 100%, 60%)" — e você deve analisá-las antes de fazer qualquer operação matemática.

Analisando Códigos Hex

Uma cor hex é uma codificação compacta de três (ou quatro, com alfa) inteiros de um byte em base 16. Analisá-la é uma questão de fatiar a string e chamar parseInt:

function parseHex(hex) {
  // Normalizar: remover # e expandir abreviação (#F53 → #FF5533)
  const clean = hex.replace('#', '');
  const full = clean.length === 3
    ? clean.split('').map(c => c + c).join('')
    : clean;

  return {
    r: parseInt(full.slice(0, 2), 16),
    g: parseInt(full.slice(2, 4), 16),
    b: parseInt(full.slice(4, 6), 16),
  };
}

parseHex('#FF5733'); // { r: 255, g: 87, b: 51 }
parseHex('#F53');    // { r: 255, g: 85, b: 51 }

O inverso — inteiros de volta a uma string hex — usa toString(16) com preenchimento de zero:

function toHex({ r, g, b }) {
  return '#' + [r, g, b]
    .map(v => Math.round(v).toString(16).padStart(2, '0'))
    .join('')
    .toUpperCase();
}

toHex({ r: 255, g: 87, b: 51 }); // '#FF5733'

Analisando Strings RGB

Strings RGB do DOM ou do CSS-in-JS frequentemente chegam como "rgb(255, 87, 51)". Uma regex extrai os três valores:

function parseRgb(str) {
  const match = str.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
  if (!match) throw new Error(`RGB inválido: ${str}`);
  return {
    r: parseInt(match[1]),
    g: parseInt(match[2]),
    b: parseInt(match[3]),
  };
}

parseRgb('rgb(255, 87, 51)');      // { r: 255, g: 87, b: 51 }
parseRgb('rgba(255, 87, 51, 0.5)'); // { r: 255, g: 87, b: 51 }

Analisando Strings HSL

Strings HSL — "hsl(11, 100%, 60%)" — exigem a extração de valores de graus e porcentagem:

function parseHsl(str) {
  const match = str.match(/hsla?\((\d+),\s*([\d.]+)%,\s*([\d.]+)%/);
  if (!match) throw new Error(`HSL inválido: ${str}`);
  return {
    h: parseFloat(match[1]),
    s: parseFloat(match[2]),
    l: parseFloat(match[3]),
  };
}

Convertendo Entre RGB e HSL

A maioria da matemática de cores opera em RGB (para mistura) ou HSL/HSV (para ajustes intuitivos). Converter entre eles é a primeira habilidade que você precisa:

function rgbToHsl({ r, g, b }) {
  const rn = r / 255, gn = g / 255, bn = b / 255;
  const max = Math.max(rn, gn, bn);
  const min = Math.min(rn, gn, bn);
  const l = (max + min) / 2;
  const d = max - min;

  if (d === 0) return { h: 0, s: 0, l: Math.round(l * 100) };

  const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
  let h;
  switch (max) {
    case rn: h = ((gn - bn) / d + (gn < bn ? 6 : 0)) / 6; break;
    case gn: h = ((bn - rn) / d + 2) / 6; break;
    default: h = ((rn - gn) / d + 4) / 6;
  }

  return {
    h: Math.round(h * 360),
    s: Math.round(s * 100),
    l: Math.round(l * 100),
  };
}

rgbToHsl({ r: 255, g: 87, b: 51 }); // { h: 11, s: 100, l: 60 }

Matemática de Cores: Clarear, Escurecer, Dessaturar

Com uma cor em HSL, ajustá-la é aritmética. Os três eixos do HSL mapeiam diretamente para os ajustes que os designers mais frequentemente solicitam.

Clareando e Escurecendo

Aumentar ou diminuir o canal l (luminosidade) é a abordagem direta:

function lighten(hex, amount) {
  const hsl = rgbToHsl(parseHex(hex));
  hsl.l = Math.min(100, hsl.l + amount);
  return hslToHex(hsl);
}

function darken(hex, amount) {
  const hsl = rgbToHsl(parseHex(hex));
  hsl.l = Math.max(0, hsl.l - amount);
  return hslToHex(hsl);
}

lighten('#FF5733', 15); // Coral mais claro
darken('#FF5733', 15);  // Vermelho-laranja mais escuro

Isso funciona bem para pequenos ajustes. Para gerar uma escala completa de 50 a 950 (como o Tailwind CSS faz), a matemática é mais envolvida porque a luminosidade percebida não é linear em HSL — o Gerador de Sombras lida com isso com distribuição ponderada perceptualmente.

Dessaturação

Diminuir o canal s (saturação) em direção a 0 transforma qualquer cor em cinza:

function desaturate(hex, amount) {
  const hsl = rgbToHsl(parseHex(hex));
  hsl.s = Math.max(0, hsl.s - amount);
  return hslToHex(hsl);
}

function grayscale(hex) {
  return desaturate(hex, 100);
}

desaturate('#FF5733', 50); // Laranja discreto e de baixa saturação
grayscale('#FF5733');      // Cinza puro com a mesma luminosidade

Misturando Duas Cores

A interpolação linear no espaço RGB é a mistura mais simples:

function mix(hex1, hex2, weight = 0.5) {
  const c1 = parseHex(hex1);
  const c2 = parseHex(hex2);
  return toHex({
    r: Math.round(c1.r * weight + c2.r * (1 - weight)),
    g: Math.round(c1.g * weight + c2.g * (1 - weight)),
    b: Math.round(c1.b * weight + c2.b * (1 - weight)),
  });
}

mix('#FF5733', '#FFFFFF', 0.7); // 70% coral, 30% branco → um tint
mix('#FF5733', '#000000', 0.7); // 70% coral, 30% preto → um shade

A mistura em RGB produz resultados perceptualmente planos entre cores complementares. Para melhores pontos intermediários, bibliotecas como chroma.js oferecem mistura nos espaços OKLCH ou Lab.

Taxa de Contraste (WCAG)

A taxa de contraste WCAG é calculada a partir da luminância relativa, não dos valores brutos dos canais. A luminância requer correção de gama — o inverso da codificação aplicada quando os valores sRGB são armazenados:

function relativeLuminance({ r, g, b }) {
  const linearize = (v) => {
    const sRGB = v / 255;
    return sRGB <= 0.04045
      ? sRGB / 12.92
      : Math.pow((sRGB + 0.055) / 1.055, 2.4);
  };
  const R = linearize(r), G = linearize(g), B = linearize(b);
  return 0.2126 * R + 0.7152 * G + 0.0722 * B;
}

function contrastRatio(hex1, hex2) {
  const L1 = relativeLuminance(parseHex(hex1));
  const L2 = relativeLuminance(parseHex(hex2));
  const lighter = Math.max(L1, L2);
  const darker = Math.min(L1, L2);
  return (lighter + 0.05) / (darker + 0.05);
}

contrastRatio('#FF5733', '#FFFFFF'); // ~3,0 — reprova WCAG AA para texto normal
contrastRatio('#000000', '#FFFFFF'); // 21,0 — contraste máximo

O WCAG AA exige 4,5:1 para texto normal e 3:1 para texto grande. Verifique qualquer par com o Conversor de Cores.


Comparação das Bibliotecas Populares: chroma.js, culori, tinycolor2

Para qualquer coisa além de ajustes simples, uma biblioteca dedicada economiza tempo significativo e evita bugs de casos extremos na matemática de cores.

chroma.js

Tamanho: ~13KB gzipado | Maturidade: 2013, ativamente mantido | Licença: BSD

chroma.js é a biblioteca de cores JavaScript mais conhecida. Sua API é fluente e encadeável:

import chroma from 'chroma-js';

// Analisar qualquer formato
const color = chroma('#FF5733');

// Ajustar
color.darken(1).hex();      // '#D93B10'
color.lighten(1).hex();     // '#FF8066'
color.saturate(0.5).hex();  // '#FF4719'
color.desaturate(0.5).hex();// '#F2653F'

// Converter
color.rgb();  // [255, 87, 51]
color.hsl();  // [11, 1, 0.6]
color.lab();  // [52.8, 47.1, 44.7]
color.oklch();// [0.63, 0.19, 27.5]

// Misturar em diferentes espaços de cor
chroma.mix('#FF5733', '#3B82F6', 0.5, 'rgb');   // Em RGB (ponto médio plano)
chroma.mix('#FF5733', '#3B82F6', 0.5, 'oklch'); // Em OKLCH (ponto médio vibrante)

// Geração de escala
const scale = chroma.scale(['#FFF5F5', '#FF5733', '#7F0000'])
  .mode('oklch')
  .colors(9);

// Contraste
chroma.contrast('#FF5733', '#FFFFFF'); // 3.0

chroma.js é a escolha certa quando você precisa de uma biblioteca abrangente e bem documentada com suporte mínimo para configuração e boa suporte para mistura em espaços de cor.

culori

Tamanho: ~6KB gzipado (tree-shakeable) | Maturidade: 2019, ativamente mantido | Licença: MIT

culori é uma biblioteca moderna e funcional projetada para tree-shaking. Cada operação é uma função autônoma — você importa apenas o que usa:

import { parse, formatHex, oklch, interpolate, formatCss } from 'culori';

// Analisar
const color = parse('#FF5733'); // { mode: 'rgb', r: 1, g: 0.34, b: 0.2 }

// Converter para OKLCH
const inOklch = oklch(color); // { mode: 'oklch', l: 0.63, c: 0.19, h: 27.5 }

// Ajustar luminosidade
const lighter = { ...inOklch, l: inOklch.l + 0.1 };
formatHex(lighter); // '#FF8469'

// Interpolação para gradientes/animação
const gradient = interpolate(['#FF5733', '#3B82F6'], 'oklch');
formatCss(gradient(0.5)); // Cor do ponto médio em formato CSS

// Geração de escala
import { samples } from 'culori';
const stops = samples(5).map(t => formatHex(gradient(t)));
// ['#FF5733', '#E85E56', '#9B6DE3', '#6186F0', '#3B82F6']

culori opera em objetos simples com uma propriedade mode, o que facilita a serialização de cores, seu armazenamento em estado ou envio por uma rede. É a melhor escolha para projetos TypeScript modernos onde o tamanho do bundle importa e o tree-shaking está em uso.

tinycolor2

Tamanho: ~5KB gzipado | Maturidade: 2012, estável (menos ativo) | Licença: MIT

tinycolor2 é o menor e mais permissivo analisador — ele aceita quase qualquer formato de string de cor, incluindo nomes de cores CSS, e funciona sem configuração:

import tinycolor from 'tinycolor2';

// Analisar quase qualquer coisa
tinycolor('red').toHexString();         // '#FF0000'
tinycolor('hsl(11, 100%, 60%)').toHexString(); // '#FF5733'
tinycolor('#F53').toHexString();        // '#FF5533'

// Ajustar
tinycolor('#FF5733').lighten(15).toHexString();    // '#FF8966'
tinycolor('#FF5733').darken(15).toHexString();     // '#C92B04'
tinycolor('#FF5733').desaturate(30).toHexString(); // '#EB6643'
tinycolor('#FF5733').spin(180).toHexString();      // '#33AEff' (complementar)

// Legibilidade / contraste
tinycolor.readability('#FF5733', '#FFFFFF'); // 3.0
tinycolor.isReadable('#FF5733', '#FFFFFF');  // false (reprova WCAG AA)
tinycolor.isReadable('#FF5733', '#FFFFFF', { level: 'AA', size: 'large' }); // true

// Harmonias de cores
tinycolor('#FF5733').triad();         // [TinyColor, TinyColor, TinyColor]
tinycolor('#FF5733').analogous();     // 6 cores análogas
tinycolor('#FF5733').complement();    // Única cor complementar

tinycolor2 é a escolha certa para projetos que precisam de análise confiável de strings de cor inseridas pelo usuário (que podem estar em qualquer formato) e manipulações básicas sem trazer uma dependência maior.

Resumo da Comparação de Bibliotecas

Recurso chroma.js culori tinycolor2
Tamanho do bundle ~13KB ~6KB (tree-shakeable) ~5KB
Espaços de cor RGB, HSL, Lab, LCH, OKLCH 20+ incluindo OKLCH, P3 RGB, HSL, HSV
Estilo da API Fluente/encadeável Funcional Orientado a objetos
TypeScript Tipos da comunidade Integrado Tipos da comunidade
Mistura de cores RGB, HSL, Lab, OKLCH Qualquer espaço de cor Somente RGB
Melhor para Uso abrangente, visualização de dados Bundles TS modernos Análise, operações simples

Construindo um Utilitário de Cores do Zero

Para uma aplicação de produção que só precisa de análise hex, clareamento, escurecimento e verificação de contraste, um utilitário sem dependências é frequentemente a melhor escolha arquitetural. Aqui está uma implementação completa:

// color-utils.js

export function parseHex(hex) {
  const clean = hex.replace('#', '');
  const full = clean.length === 3
    ? clean.split('').map(c => c + c).join('')
    : clean;
  return {
    r: parseInt(full.slice(0, 2), 16),
    g: parseInt(full.slice(2, 4), 16),
    b: parseInt(full.slice(4, 6), 16),
  };
}

export function toHex({ r, g, b }) {
  return '#' + [r, g, b]
    .map(v => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0'))
    .join('').toUpperCase();
}

function rgbToHsl({ r, g, b }) {
  const rn = r / 255, gn = g / 255, bn = b / 255;
  const max = Math.max(rn, gn, bn), min = Math.min(rn, gn, bn);
  const l = (max + min) / 2;
  const d = max - min;
  if (d === 0) return { h: 0, s: 0, l };
  const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
  let h;
  switch (max) {
    case rn: h = ((gn - bn) / d + (gn < bn ? 6 : 0)) / 6; break;
    case gn: h = ((bn - rn) / d + 2) / 6; break;
    default: h = ((rn - gn) / d + 4) / 6;
  }
  return { h, s, l };
}

function hslToRgb({ h, s, l }) {
  if (s === 0) {
    const v = Math.round(l * 255);
    return { r: v, g: v, b: v };
  }
  const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
  const p = 2 * l - q;
  const hue2rgb = (p, q, t) => {
    if (t < 0) t += 1;
    if (t > 1) t -= 1;
    if (t < 1/6) return p + (q - p) * 6 * t;
    if (t < 1/2) return q;
    if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
    return p;
  };
  return {
    r: Math.round(hue2rgb(p, q, h + 1/3) * 255),
    g: Math.round(hue2rgb(p, q, h) * 255),
    b: Math.round(hue2rgb(p, q, h - 1/3) * 255),
  };
}

export function lighten(hex, amount) {
  const hsl = rgbToHsl(parseHex(hex));
  return toHex(hslToRgb({ ...hsl, l: Math.min(1, hsl.l + amount / 100) }));
}

export function darken(hex, amount) {
  const hsl = rgbToHsl(parseHex(hex));
  return toHex(hslToRgb({ ...hsl, l: Math.max(0, hsl.l - amount / 100) }));
}

export function desaturate(hex, amount) {
  const hsl = rgbToHsl(parseHex(hex));
  return toHex(hslToRgb({ ...hsl, s: Math.max(0, hsl.s - amount / 100) }));
}

function linearize(v) {
  const sRGB = v / 255;
  return sRGB <= 0.04045 ? sRGB / 12.92 : Math.pow((sRGB + 0.055) / 1.055, 2.4);
}

export function relativeLuminance(hex) {
  const { r, g, b } = parseHex(hex);
  return 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b);
}

export function contrastRatio(hex1, hex2) {
  const L1 = relativeLuminance(hex1);
  const L2 = relativeLuminance(hex2);
  const lighter = Math.max(L1, L2);
  const darker = Math.min(L1, L2);
  return (lighter + 0.05) / (darker + 0.05);
}

export function isWcagAA(foreground, background, largeText = false) {
  const ratio = contrastRatio(foreground, background);
  return ratio >= (largeText ? 3.0 : 4.5);
}

export function bestTextColor(background) {
  const L = relativeLuminance(background);
  return L > 0.179 ? '#000000' : '#FFFFFF';
}

Uso:

import { lighten, darken, contrastRatio, bestTextColor } from './color-utils.js';

const brand = '#FF5733';
const hover = darken(brand, 10);       // Mais escuro para :hover
const light = lighten(brand, 30);      // Tint claro para fundos
const text = bestTextColor(brand);     // Preto ou branco para texto sobre a cor da marca

console.log(contrastRatio(text, brand)); // Deve ser ≥ 4.5

Considerações de Desempenho para Cores em Tempo de Execução

Cache Agressivo

Os cálculos de cores são referencialmente transparentes — a mesma entrada hex sempre produz a mesma saída. Um simples wrapper de memoização evita recálculos a cada renderização:

function memoize(fn) {
  const cache = new Map();
  return (...args) => {
    const key = JSON.stringify(args);
    if (!cache.has(key)) cache.set(key, fn(...args));
    return cache.get(key);
  };
}

const lightenCached = memoize(lighten);
const contrastCached = memoize(contrastRatio);

Propriedades Customizadas CSS em vez de JavaScript

Para tematização dinâmica, não recalcule cores em JavaScript a cada mudança de estado. Calcule a paleta uma vez, escreva-a nas propriedades customizadas CSS e deixe o CSS lidar com cada componente:

function applyTheme(brandHex) {
  const root = document.documentElement;
  root.style.setProperty('--brand', brandHex);
  root.style.setProperty('--brand-dark', darken(brandHex, 10));
  root.style.setProperty('--brand-light', lighten(brandHex, 30));
  root.style.setProperty('--on-brand', bestTextColor(brandHex));
}

// Chamado uma vez quando a cor da marca muda, não a cada renderização
applyTheme('#FF5733');

Evite Analisar Strings em Loops de Renderização

Analisar "rgb(255, 87, 51)" dentro de uma função de renderização React que roda 60 vezes por segundo é ineficiente. Analise uma vez, armazene o resultado e passe objetos de cor estruturados pela árvore de componentes em vez de strings brutas.

// Custoso: analisa a cada chamada
const color = chroma(colorString).darken(1).hex();

// Melhor: analise uma vez, transforme muitas vezes
const parsed = chroma(colorString);
const darkVariant = parsed.darken(1).hex();
const lightVariant = parsed.lighten(1).hex();

Para geração de cores em tempo de execução em escala — seletores de cor que atualizam a cada movimento do mouse, ou visualizações com centenas de cores calculadas — culori é a melhor escolha porque sua arquitetura funcional e sem estado e o tamanho reduzido do bundle têm o menor overhead por chamada.


Principais Conclusões

  • JavaScript não tem tipo de cor nativo — analise strings hex, RGB e HSL em objetos estruturados antes de fazer qualquer cálculo.
  • A aritmética HSL (ajustar l para luminosidade, s para saturação) cobre a maioria das transformações de design comuns diretamente.
  • A taxa de contraste WCAG requer cálculo de luminância com correção de gama — não a aproxime com a matemática do canal de luminosidade.
  • chroma.js é a biblioteca mais completa com excelente suporte a espaços de cor incluindo mistura OKLCH; culori é a escolha moderna tree-shakeable para projetos TypeScript; tinycolor2 é o analisador mais permissivo para cenários de entrada do usuário.
  • Um utilitário sem dependências é viável para aplicações que precisam apenas de análise, clareamento/escurecimento e contraste — a implementação completa tem menos de 100 linhas.
  • Faça cache agressivo das cores calculadas e escreva os resultados nas propriedades customizadas CSS em vez de recalcular por renderização.
  • Use o Conversor de Cores para verificar visualmente qualquer transformação de cor, e o Gerador de Sombras para gerar escalas completas de 50 a 950 compatíveis com Tailwind a partir de um único hex de marca.

Cores relacionadas

Marcas relacionadas

Ferramentas relacionadas