Manipulación de colores en JavaScript: librerías y técnicas
Embed This Widget
Add the script tag and a data attribute to embed this widget.
Embed via iframe for maximum compatibility.
<iframe src="https://colorfyi.com/iframe/entity//" width="420" height="400" frameborder="0" style="border:0;border-radius:10px;max-width:100%" loading="lazy"></iframe>
Paste this URL in WordPress, Medium, or any oEmbed-compatible platform.
https://colorfyi.com/entity//
Add a dynamic SVG badge to your README or docs.
[](https://colorfyi.com/entity//)
Use the native HTML custom element.
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
lpara la luminosidad,spara 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.