Уроки

Работа с цветом в JavaScript: библиотеки и техники

11 мин чтения

Цвет в веб-приложениях — не статическое значение, которое копируют из файла дизайна и оставляют в покое. Цвета светлеют при наведении, темнеют при нажатии, корректируются под требования контраста доступности, анимируются между состояниями и адаптируются к выбранным пользователем темам. Всё это требует программного управления цветом — умения разбирать, преобразовывать и генерировать цветовые значения в JavaScript во время выполнения.

Это руководство охватывает работу JavaScript с цветом на нативном уровне, математику распространённых преобразований, сравнение трёх наиболее широко используемых библиотек, а также создание минимальной цветовой утилиты без зависимостей.


Разбор Hex, RGB и HSL в JavaScript

В JavaScript нет встроенного типа для цвета. Цвета приходят в виде строк — "#FF5733", "rgb(255, 87, 51)", "hsl(11, 100%, 60%)" — и их необходимо разобрать вручную, прежде чем производить вычисления.

Разбор Hex-кодов

Hex-цвет — это компактная кодировка трёх (или четырёх, с альфой) однобайтовых целых в шестнадцатеричной системе. Разбор сводится к нарезке строки и вызову parseInt:

function parseHex(hex) {
  // Нормализация: убираем # и раскрываем сокращённую запись (#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 }

Обратное преобразование — целых обратно в hex-строку — использует toString(16) с дополнением нулями:

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'

Разбор RGB-строк

RGB-строки из DOM или CSS-in-JS нередко имеют вид "rgb(255, 87, 51)". Регулярное выражение извлекает три значения:

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

Разбор HSL-строк

HSL-строки — "hsl(11, 100%, 60%)" — требуют извлечения значений градусов и процентов:

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

Конвертация между RGB и HSL

Большинство цветовых вычислений производится либо в RGB (для смешивания), либо в HSL/HSV (для интуитивных корректировок). Конвертация между ними — первый необходимый навык:

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 }

Цветовая математика: осветление, затемнение, обесцвечивание

Получив цвет в HSL, управлять им легко с помощью арифметики. Три оси HSL напрямую соответствуют корректировкам, которые дизайнеры запрашивают чаще всего.

Осветление и затемнение

Увеличение или уменьшение канала l (светлота) — прямолинейный подход:

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); // Более светлый коралловый
darken('#FF5733', 15);  // Более тёмный красно-оранжевый

Это хорошо работает для небольших корректировок. Для генерации полной шкалы 50–950 (как в Tailwind CSS) математика сложнее, поскольку воспринимаемая светлота не линейна в HSL — Генератор оттенков обрабатывает это с перцептивно взвешенным распределением.

Обесцвечивание

Уменьшение канала s (насыщенность) к нулю превращает любой цвет в серый:

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); // Приглушённый, малонасыщенный оранжевый
grayscale('#FF5733');      // Чистый серый с той же светлотой

Смешивание двух цветов

Линейная интерполяция в пространстве RGB — простейшее смешивание:

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% коралл, 30% белый → тинт
mix('#FF5733', '#000000', 0.7); // 70% коралл, 30% чёрный → шейд

RGB-смешивание даёт перцептивно плоские результаты между дополнительными цветами. Для лучших промежуточных точек библиотеки вроде chroma.js предлагают смешивание в пространствах OKLCH или Lab.

Коэффициент контраста (WCAG)

Коэффициент контраста WCAG вычисляется из относительной яркости, а не из сырых значений каналов. Яркость требует гамма-коррекции — обратной той, что применяется при хранении 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 — не проходит WCAG AA для обычного текста
contrastRatio('#000000', '#FFFFFF'); // 21.0 — максимальный контраст

WCAG AA требует 4,5:1 для обычного текста и 3:1 для крупного. Проверьте любую пару с помощью Конвертера цвета.


Сравнение популярных библиотек: chroma.js, culori, tinycolor2

Для всего, что выходит за рамки простых корректировок, специализированная библиотека экономит значительное время и избавляет от крайних случаев в цветовой математике.

chroma.js

Размер: ~13 КБ в сжатом виде | Зрелость: 2013, активно поддерживается | Лицензия: BSD

chroma.js — наиболее известная библиотека для работы с цветом в JavaScript. Её API беглый и цепочный:

import chroma from 'chroma-js';

// Разбор любого формата
const color = chroma('#FF5733');

// Корректировки
color.darken(1).hex();      // '#D93B10'
color.lighten(1).hex();     // '#FF8066'
color.saturate(0.5).hex();  // '#FF4719'
color.desaturate(0.5).hex();// '#F2653F'

// Конвертация
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]

// Смешивание в разных цветовых пространствах
chroma.mix('#FF5733', '#3B82F6', 0.5, 'rgb');   // В RGB (плоская средняя точка)
chroma.mix('#FF5733', '#3B82F6', 0.5, 'oklch'); // В OKLCH (насыщенная средняя точка)

// Генерация шкалы
const scale = chroma.scale(['#FFF5F5', '#FF5733', '#7F0000'])
  .mode('oklch')
  .colors(9);

// Контраст
chroma.contrast('#FF5733', '#FFFFFF'); // 3.0

chroma.js — правильный выбор, когда нужна комплексная, хорошо документированная библиотека с минимальной настройкой и отличной поддержкой смешивания в цветовых пространствах.

culori

Размер: ~6 КБ в сжатом виде (tree-shakeable) | Зрелость: 2019, активно поддерживается | Лицензия: MIT

culori — современная функциональная библиотека, разработанная для tree-shaking. Каждая операция — отдельная функция: вы импортируете только то, что используете:

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

// Разбор
const color = parse('#FF5733'); // { mode: 'rgb', r: 1, g: 0.34, b: 0.2 }

// Конвертация в OKLCH
const inOklch = oklch(color); // { mode: 'oklch', l: 0.63, c: 0.19, h: 27.5 }

// Корректировка светлоты
const lighter = { ...inOklch, l: inOklch.l + 0.1 };
formatHex(lighter); // '#FF8469'

// Интерполяция для градиентов/анимации
const gradient = interpolate(['#FF5733', '#3B82F6'], 'oklch');
formatCss(gradient(0.5)); // Средняя точка цвета в формате CSS

// Генерация шкалы
import { samples } from 'culori';
const stops = samples(5).map(t => formatHex(gradient(t)));
// ['#FF5733', '#E85E56', '#9B6DE3', '#6186F0', '#3B82F6']

culori работает с простыми объектами, имеющими свойство mode, что упрощает сериализацию цветов, хранение в состоянии и передачу по сети. Это лучший выбор для современных TypeScript-проектов, где важен размер бандла и задействован tree-shaking.

tinycolor2

Размер: ~5 КБ в сжатом виде | Зрелость: 2012, стабильна (менее активна) | Лицензия: MIT

tinycolor2 — наименьший и наиболее толерантный парсер: принимает почти любой формат строки цвета, включая именованные цвета CSS, и работает без настройки:

import tinycolor from 'tinycolor2';

// Разбор почти чего угодно
tinycolor('red').toHexString();         // '#FF0000'
tinycolor('hsl(11, 100%, 60%)').toHexString(); // '#FF5733'
tinycolor('#F53').toHexString();        // '#FF5533'

// Корректировки
tinycolor('#FF5733').lighten(15).toHexString();    // '#FF8966'
tinycolor('#FF5733').darken(15).toHexString();     // '#C92B04'
tinycolor('#FF5733').desaturate(30).toHexString(); // '#EB6643'
tinycolor('#FF5733').spin(180).toHexString();      // '#33AEff' (дополнительный цвет)

// Читаемость / контраст
tinycolor.readability('#FF5733', '#FFFFFF'); // 3.0
tinycolor.isReadable('#FF5733', '#FFFFFF');  // false (не проходит WCAG AA)
tinycolor.isReadable('#FF5733', '#FFFFFF', { level: 'AA', size: 'large' }); // true

// Цветовые гармонии
tinycolor('#FF5733').triad();         // [TinyColor, TinyColor, TinyColor]
tinycolor('#FF5733').analogous();     // 6 аналогичных цветов
tinycolor('#FF5733').complement();    // Один дополнительный цвет

tinycolor2 — правильный выбор для проектов, которым нужен надёжный парсинг введённых пользователем строк цвета (в любом формате) и базовые операции без подключения более крупной зависимости.

Итоговое сравнение библиотек

Возможность chroma.js culori tinycolor2
Размер бандла ~13 КБ ~6 КБ (tree-shakeable) ~5 КБ
Цветовые пространства RGB, HSL, Lab, LCH, OKLCH 20+ включая OKLCH, P3 RGB, HSL, HSV
Стиль API Беглый/цепочный Функциональный Объектно-ориентированный
TypeScript Типы сообщества Встроенные Типы сообщества
Смешивание цветов RGB, HSL, Lab, OKLCH Любое цветовое пространство Только RGB
Лучше всего для Комплексного использования, dataviz Современных TS-бандлов Разбора, простых операций

Создание цветовой утилиты с нуля

Для продакшн-приложения, которому нужны только разбор hex, осветление, затемнение и проверка контраста, утилита без зависимостей нередко является лучшим архитектурным решением. Вот полная реализация:

// 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';
}

Использование:

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

const brand = '#FF5733';
const hover = darken(brand, 10);       // Темнее для :hover
const light = lighten(brand, 30);      // Светлый тинт для фонов
const text = bestTextColor(brand);     // Чёрный или белый для текста на бренд-цвете

console.log(contrastRatio(text, brand)); // Должно быть ≥ 4.5

Соображения о производительности при работе с цветом во время выполнения

Агрессивное кэширование

Вычисления цвета referentially transparent — одни и те же входные данные hex всегда дают одинаковый результат. Простая обёртка для мемоизации избавляет от повторных вычислений при каждом рендере:

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

CSS Custom Properties вместо JavaScript

Для динамической темизации не пересчитывайте цвета в JavaScript при каждом изменении состояния. Вычислите палитру один раз, запишите её в CSS custom properties и позвольте CSS обрабатывать каждый компонент:

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

// Вызывается один раз при изменении бренд-цвета, а не при каждом рендере
applyTheme('#FF5733');

Избегайте разбора строк в циклах рендеринга

Разбор "rgb(255, 87, 51)" внутри функции рендеринга React, выполняющейся 60 раз в секунду, расточителен. Разбирайте один раз, сохраняйте результат и передавайте структурированные объекты цвета через дерево компонентов, а не сырые строки.

// Дорого: разбирает при каждом вызове
const color = chroma(colorString).darken(1).hex();

// Лучше: разбираем один раз, преобразуем много раз
const parsed = chroma(colorString);
const darkVariant = parsed.darken(1).hex();
const lightVariant = parsed.lighten(1).hex();

Для генерации цвета во время выполнения в большом масштабе — пикеры цвета, обновляющиеся при каждом движении мыши, или визуализации с сотнями вычисляемых цветов — culori является лучшим выбором, поскольку его функциональная, статeless архитектура и компактный бандл обеспечивают минимальные накладные расходы на каждый вызов.


Ключевые выводы

  • В JavaScript нет нативного типа цвета — разбирайте строки hex, RGB и HSL в структурированные объекты перед выполнением любых вычислений.
  • HSL-арифметика (корректировка l для светлоты, s для насыщенности) покрывает большинство распространённых дизайнерских преобразований напрямую.
  • Коэффициент контраста WCAG требует вычисления яркости с гамма-коррекцией — не аппроксимируйте его через математику канала светлоты.
  • chroma.js — наиболее полная библиотека с отличной поддержкой цветовых пространств, включая смешивание OKLCH; culori — современный tree-shakeable выбор для TypeScript-проектов; tinycolor2 — наиболее толерантный парсер для сценариев с вводом пользователя.
  • Утилита без зависимостей жизнеспособна для приложений, которым нужны только разбор, осветление/затемнение и контраст — полная реализация занимает менее 100 строк.
  • Агрессивно кэшируйте вычисленные цвета и записывайте результаты в CSS custom properties, а не пересчитывайте при каждом рендере.
  • Используйте Конвертер цвета, чтобы визуально проверить любое цветовое преобразование, и Генератор оттенков для создания полных шкал 50–950 совместимых с Tailwind из одного hex бренд-цвета.

Похожие цвета

Похожие бренды

Похожие инструменты