Tutoriales

Manipulación de colores en JavaScript: librerías y técnicas

12 min de lectura

El color en las aplicaciones web no es un valor estático que pegues desde un archivo de diseño y dejes tal cual. Los colores se aclaran al pasar el ratón, se oscurecen al presionar, se ajustan para cumplir los requisitos de contraste de accesibilidad, se animan entre estados y se adaptan a los temas seleccionados por el usuario. Todo eso requiere manipulación programática del color — la capacidad de analizar, transformar y generar valores de color en JavaScript en tiempo de ejecución.

Esta guía cubre cómo JavaScript maneja el color de forma nativa, las matemáticas detrás de las transformaciones comunes, una comparación de las tres librerías más utilizadas y cómo construir una utilidad de color mínima cuando quieres cero dependencias.


Análisis de Hex, RGB y HSL en JavaScript

JavaScript no tiene un tipo de dato de color nativo. Los colores llegan como cadenas de texto — "#FF5733", "rgb(255, 87, 51)", "hsl(11, 100%, 60%)" — y debes analizarlos tú mismo antes de hacer cualquier cálculo.

Análisis de códigos Hex

Un color hex es una codificación compacta de tres (o cuatro, con alfa) enteros de un byte en base 16. Analizarlo consiste en cortar la cadena y llamar a parseInt:

function parseHex(hex) {
  // Normalizar: quitar # y expandir abreviatura (#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 }

El proceso inverso — enteros de vuelta a una cadena hex — usa toString(16) con relleno de ceros:

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'

Análisis de cadenas RGB

Las cadenas RGB del DOM o CSS-in-JS suelen llegar como "rgb(255, 87, 51)". Una expresión regular extrae los tres valores:

function parseRgb(str) {
  const match = str.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
  if (!match) throw new Error(`RGB no vá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 }

Análisis de cadenas HSL

Las cadenas HSL — "hsl(11, 100%, 60%)" — requieren extraer los valores de grado y porcentaje:

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

Conversión entre RGB y HSL

La mayoría de las operaciones matemáticas de color trabajan en RGB (para mezcla) o HSL/HSV (para ajustes intuitivos). La conversión entre ambos es la primera habilidad que necesitas:

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áticas del color: aclarar, oscurecer, desaturar

Una vez que tienes un color en HSL, ajustarlo es aritmética. Los tres ejes del HSL se corresponden directamente con los ajustes que los diseñadores más frecuentemente solicitan.

Aclarar y oscurecer

Aumentar o disminuir el canal l (luminosidad) es el enfoque más directo:

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 más claro
darken('#FF5733', 15);  // Naranja-rojo más oscuro

Esto funciona bien para ajustes pequeños. Para generar una escala completa del 50 al 950 (como hace Tailwind CSS), las matemáticas son más complejas porque la luminosidad percibida no es lineal en HSL — el Generador de sombras gestiona esto con una distribución ponderada perceptualmente.

Desaturación

Disminuir el canal s (saturación) hacia 0 convierte cualquier color en gris:

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); // Naranja apagado, baja saturación
grayscale('#FF5733');      // Gris puro con la misma luminosidad

Mezcla de dos colores

La interpolación lineal en el espacio RGB es la mezcla más simple:

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% blanco → un tinte
mix('#FF5733', '#000000', 0.7); // 70% coral, 30% negro → una sombra

La mezcla RGB produce resultados perceptualmente planos entre colores complementarios. Para mejores puntos medios, librerías como chroma.js ofrecen mezcla en espacios OKLCH o Lab.

Ratio de contraste (WCAG)

El ratio de contraste WCAG se calcula a partir de la luminancia relativa, no de los valores brutos de los canales. La luminancia requiere corrección gamma — la inversa de la codificación aplicada cuando se almacenan los valores sRGB:

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 — falla WCAG AA para texto normal
contrastRatio('#000000', '#FFFFFF'); // 21,0 — contraste máximo

WCAG AA requiere 4,5:1 para texto normal y 3:1 para texto grande. Verifica cualquier par con el Conversor de color.


Comparación de librerías populares: chroma.js, culori, tinycolor2

Para cualquier cosa más allá de ajustes simples, una librería dedicada ahorra tiempo significativo y evita errores en casos extremos de las matemáticas del color.

chroma.js

Tamaño: ~13KB gzipped | Madurez: 2013, mantenido activamente | Licencia: BSD

chroma.js es la librería de color JavaScript más conocida. Su API es fluida y encadenable:

import chroma from 'chroma-js';

// Analizar cualquier 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'

// Convertir
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]

// Mezclar en diferentes espacios de color
chroma.mix('#FF5733', '#3B82F6', 0.5, 'rgb');   // En RGB (punto medio plano)
chroma.mix('#FF5733', '#3B82F6', 0.5, 'oklch'); // En OKLCH (punto medio vibrante)

// Generación de escalas
const scale = chroma.scale(['#FFF5F5', '#FF5733', '#7F0000'])
  .mode('oklch')
  .colors(9);

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

chroma.js es la elección correcta cuando necesitas una librería completa y bien documentada con configuración mínima y buen soporte para mezcla en espacios de color.

culori

Tamaño: ~6KB gzipped (tree-shakeable) | Madurez: 2019, mantenido activamente | Licencia: MIT

culori es una librería moderna y funcional diseñada para tree-shaking. Cada operación es una función independiente — importas solo lo que usas:

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

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

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

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

// Interpolación para gradientes/animación
const gradient = interpolate(['#FF5733', '#3B82F6'], 'oklch');
formatCss(gradient(0.5)); // Color del punto medio en formato CSS

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

culori opera sobre objetos planos con una propiedad mode, lo que facilita serializar colores, almacenarlos en estado o enviarlos por la red. Es la mejor opción para proyectos modernos de TypeScript donde el tamaño del bundle importa y el tree-shaking está activo.

tinycolor2

Tamaño: ~5KB gzipped | Madurez: 2012, estable (menos activo) | Licencia: MIT

tinycolor2 es el analizador más pequeño y permisivo — acepta casi cualquier formato de cadena de color, incluidos los colores con nombre de CSS, y funciona sin configuración:

import tinycolor from 'tinycolor2';

// Analizar casi cualquier cosa
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' (complementario)

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

// Armonías de color
tinycolor('#FF5733').triad();         // [TinyColor, TinyColor, TinyColor]
tinycolor('#FF5733').analogous();     // 6 colores análogos
tinycolor('#FF5733').complement();    // Color complementario único

tinycolor2 es la opción correcta para proyectos que necesitan un análisis confiable de cadenas de color ingresadas por el usuario (que pueden estar en cualquier formato) y manipulaciones básicas sin incorporar una dependencia mayor.

Resumen comparativo de librerías

Característica chroma.js culori tinycolor2
Tamaño del bundle ~13KB ~6KB (tree-shakeable) ~5KB
Espacios de color RGB, HSL, Lab, LCH, OKLCH 20+ incluyendo OKLCH, P3 RGB, HSL, HSV
Estilo de API Fluida/encadenable Funcional Orientada a objetos
TypeScript Tipos de la comunidad Integrado Tipos de la comunidad
Mezcla de colores RGB, HSL, Lab, OKLCH Cualquier espacio de color Solo RGB
Mejor para Uso completo, visualización de datos Bundles TS modernos Análisis, operaciones simples

Construir una utilidad de color desde cero

Para una aplicación en producción que solo necesita análisis hex, aclarar, oscurecer y verificar contraste, una utilidad sin dependencias es a menudo la mejor elección arquitectónica. Aquí hay una implementación 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);       // Más oscuro para :hover
const light = lighten(brand, 30);      // Tinte claro para fondos
const text = bestTextColor(brand);     // Negro o blanco para texto sobre la marca

console.log(contrastRatio(text, brand)); // Debería ser ≥ 4.5

Consideraciones de rendimiento para el color en tiempo de ejecución

Cachear agresivamente

Los cálculos de color son referencialmente transparentes — la misma entrada hex siempre produce la misma salida. Un envoltorio de memoización simple evita recalcular en cada renderizado:

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);

Propiedades personalizadas de CSS sobre JavaScript

Para el tematizado dinámico, no recalcules los colores en JavaScript en cada cambio de estado. Computa la paleta una vez, escríbela en propiedades personalizadas de CSS y deja que CSS gestione 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));
}

// Se llama una vez cuando cambia el color de marca, no en cada renderizado
applyTheme('#FF5733');

Evitar el análisis de cadenas en bucles de renderizado

Analizar "rgb(255, 87, 51)" dentro de una función de renderizado de React que se ejecuta 60 veces por segundo es un desperdicio. Analiza una vez, almacena el resultado y pasa objetos de color estructurados a través de tu árbol de componentes en lugar de cadenas sin procesar.

// Costoso: analiza en cada llamada
const color = chroma(colorString).darken(1).hex();

// Mejor: analiza una vez, transforma muchas veces
const parsed = chroma(colorString);
const darkVariant = parsed.darken(1).hex();
const lightVariant = parsed.lighten(1).hex();

Para la generación de color en tiempo de ejecución a escala — selectores de color que se actualizan en cada movimiento del ratón, o visualizaciones con cientos de colores calculados — culori es la mejor opción porque su arquitectura funcional y sin estado y su bundle reducido tienen la menor sobrecarga por llamada.


Conclusiones clave

  • JavaScript no tiene un tipo de color nativo — analiza las cadenas hex, RGB y HSL en objetos estructurados antes de hacer cualquier cálculo.
  • La aritmética HSL (ajustar l para la luminosidad, s para la saturación) cubre la mayoría de las transformaciones de diseño comunes directamente.
  • El ratio de contraste WCAG requiere el cálculo de luminancia con corrección gamma — no lo aproximes con las matemáticas del canal de luminosidad.
  • chroma.js es la librería más completa con excelente soporte de espacios de color incluida la mezcla OKLCH; culori es la opción moderna tree-shakeable para proyectos TypeScript; tinycolor2 es el analizador más permisivo para escenarios de entrada del usuario.
  • Una utilidad sin dependencias es viable para aplicaciones que solo necesitan análisis, aclarar/oscurecer y contraste — la implementación completa tiene menos de 100 líneas.
  • Cachea los colores calculados agresivamente y escribe los resultados en propiedades personalizadas de CSS en lugar de recalcular por renderizado.
  • Usa el Conversor de color para verificar visualmente cualquier transformación de color, y el Generador de sombras para generar escalas completas del 50 al 950 compatibles con Tailwind desde un único hex de marca.

Colores relacionados

Marcas relacionadas

Herramientas relacionadas