チュートリアル

JavaScriptの色操作:ライブラリとテクニック

7分で読める

Webアプリケーションの色は、デザインファイルから貼り付けて放置する静的な値ではありません。色はホバー時に明るくなり、押下時に暗くなり、アクセシビリティのコントラスト要件に合わせて調整され、状態間でアニメーション化され、ユーザーが選択したテーマに対応します。これらすべては、プログラム的な色操作、つまりJavaScriptで実行時に色値を構文解析、変換、生成する機能を必要とします。

このガイドでは、JavaScriptが色をネイティブに処理する方法、一般的な変換の背後にある数学、最も広く使用されている3つのライブラリの比較、および依存関係なしで色ユーティリティを構築する方法について説明します。


JavaScriptでHex、RGB、HSLを構文解析する

JavaScriptには組み込みの色型がありません。色は文字列として到着します。"#FF5733""rgb(255, 87, 51)""hsl(11, 100%, 60%)" のように到着し、数学を実行する前に自分で構文解析する必要があります。

Hex コードの構文解析

Hex 色は、16進数(16進数)の3つ(または4つ、アルファ付き)の1バイト整数のコンパクトな符号化です。これを構文解析することは、文字列をスライスして parseInt を呼び出すという問題です:

function parseHex(hex) {
  // Normalize: strip # and expand shorthand (#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 }

逆に、整数を16進数文字列に戻すには、ゼロパディングと共に 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 文字列の構文解析

DOMまたはCSS-in-JSからのRGB文字列は、"rgb(255, 87, 51)" として到着することがよくあります。正規表現は3つの値を抽出します:

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の3つの軸は、デザイナーが最も一般的にリクエストする調整に直接マッピングされます。

明るくしたり暗くしたりする

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); // Lighter coral
darken('#FF5733', 15);  // Darker red-orange

これは小さな調整に適しています。50–950スケール全体を生成する場合(Tailwind CSSが行うように)、知覚される明度はHSLで線形ではないため、数学がより複雑になります。シェードジェネレーターは、知覚的に加重された分布でこれを処理します。

彩度を下げる

s(彩度)チャネルを0に向けて減らすと、任意の色がグレーになります:

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); // Muted, low-saturation orange
grayscale('#FF5733');      // Pure gray with the same lightness

2つの色をブレンドする

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% coral, 30% white → a tint
mix('#FF5733', '#000000', 0.7); // 70% coral, 30% black → a shade

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 — fails WCAG AA for normal text
contrastRatio('#000000', '#FFFFFF'); // 21.0 — maximum contrast

WCAG AAでは、通常のテキストには4.5:1、大きなテキストには3:1が必要です。色コンバーターを使用して任意のペアをチェックしてください。


人気のあるライブラリの比較:chroma.js、culori、tinycolor2

簡単な調整を超えるもの場合、専用ライブラリは大幅な時間を節約し、色数学のエッジケースバグを回避します。

chroma.js

サイズ: ~13KB gzipped | 成熟度: 2013、積極的にメンテナンス中 | ライセンス: BSD

chroma.jsは最も広く知られているJavaScript色ライブラリです。そのAPIはフルエントであり、チェーン可能です:

import chroma from 'chroma-js';

// Parse any format
const color = chroma('#FF5733');

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

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

// Mix in different color spaces
chroma.mix('#FF5733', '#3B82F6', 0.5, 'rgb');   // In RGB (flat midpoint)
chroma.mix('#FF5733', '#3B82F6', 0.5, 'oklch'); // In OKLCH (vibrant midpoint)

// Scale generation
const scale = chroma.scale(['#FFF5F5', '#FF5733', '#7F0000'])
  .mode('oklch')
  .colors(9);

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

chroma.jsは、最小限の設定で包括的でよくドキュメント化されたライブラリが必要で、色空間ミキシングの優れたサポートが必要な場合に適切な選択です。

culori

サイズ: ~6KB gzipped(ツリーシェイク可能) | 成熟度: 2019、積極的にメンテナンス中 | ライセンス: MIT

culoriは、ツリーシェイキング用に設計された最新の関数型ライブラリです。すべての操作はスタンドアロン関数です。使用するものだけをインポートします:

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

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

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

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

// Interpolation for gradients/animation
const gradient = interpolate(['#FF5733', '#3B82F6'], 'oklch');
formatCss(gradient(0.5)); // Midpoint color in CSS format

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

culoriはmodeプロパティを持つプレーンオブジェクトで動作し、これにより色をシリアル化し、状態に保存し、ネットワーク経由で送信することが簡単になります。バンドルサイズが重要であり、ツリーシェイキングが機能している最新のTypeScriptプロジェクトに最適な選択肢です。

tinycolor2

サイズ: ~5KB gzipped | 成熟度: 2012、安定版(アクティブは少ない) | ライセンス: MIT

tinycolor2は、最小で最も寛容なパーサーです。CSSの名前付き色を含む、ほぼすべての色文字列形式を受け入れ、設定なしで動作します:

import tinycolor from 'tinycolor2';

// Parse almost anything
tinycolor('red').toHexString();         // '#FF0000'
tinycolor('hsl(11, 100%, 60%)').toHexString(); // '#FF5733'
tinycolor('#F53').toHexString();        // '#FF5533'

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

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

// Color harmonies
tinycolor('#FF5733').triad();         // [TinyColor, TinyColor, TinyColor]
tinycolor('#FF5733').analogous();     // 6 analogous colors
tinycolor('#FF5733').complement();    // Single complementary color

tinycolor2は、任意の形式でユーザーが入力した色文字列(任意の形式である可能性があります)の信頼できる構文解析と、より大きな依存関係を引き込まずに基本的な操作が必要なプロジェクトに適切な選択肢です。

ライブラリ比較の概要

機能 chroma.js culori tinycolor2
バンドルサイズ ~13KB ~6KB(ツリーシェイク可能) ~5KB
色空間 RGB、HSL、Lab、LCH、OKLCH 20以上(OKLCH、P3を含む) RGB、HSL、HSV
APIスタイル フルエント/チェーン可能 関数型 オブジェクト指向
TypeScript コミュニティ型 組み込み コミュニティ型
色ミキシング RGB、HSL、Lab、OKLCH 任意の色空間 RGBのみ
最適用途 包括的な使用、データ視覚化 最新の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);       // Darker for :hover
const light = lighten(brand, 30);      // Light tint for backgrounds
const text = bestTextColor(brand);     // Black or white for on-brand text

console.log(contrastRatio(text, brand)); // Should be ≥ 4.5

ランタイムカラーのパフォーマンスに関する考慮事項

積極的にキャッシュする

色計算は参照的に透過的です。同じ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);

JavaScriptではなくCSS カスタムプロパティ

動的なテーマ作成の場合、状態が変わるたびにJavaScriptで色を再計算しないでください。パレットを一度計算し、CSSカスタムプロパティに書き込み、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));
}

// Called once when brand color changes, not on every render
applyTheme('#FF5733');

レンダリングループで文字列を構文解析することは避けてください

毎秒60回実行されるReactレンダー関数の内部で "rgb(255, 87, 51)" を構文解析することは無駄です。一度構文解析し、結果を保存し、raw文字列ではなく構造化された色オブジェクトをコンポーネントツリーに渡します。

// Expensive: parses on every call
const color = chroma(colorString).darken(1).hex();

// Better: parse once, transform many times
const parsed = chroma(colorString);
const darkVariant = parsed.darken(1).hex();
const lightVariant = parsed.lighten(1).hex();

ランタイム色生成が大規模な場合(マウスを動かすたびに更新する色ピッカー、または数百の計算色を持つ視覚化)、culoriが最良の選択肢です。その関数型、ステートレスアーキテクチャと緊密なバンドルサイズは、呼び出しごとのオーバーヘッドが最も少ないためです。


主要な要点

  • JavaScriptには組み込みの色型がありません。Hex、RGB、HSL文字列を構造化オブジェクトに構文解析してから、数学を実行します。
  • HSL演算(明度の l、彩度の s を調整)は、ほとんどの一般的な設計変換を直接カバーしています。
  • WCAGコントラスト比には、明度チャネルの数学で近似しない、ガンマ補正を伴う輝度計算が必要です。
  • chroma.js は、OKLCH混合を含む優れた色空間サポートを備えた最も完全なライブラリです。 culori は、TypeScriptプロジェクト用の最新のツリーシェイク可能な選択肢です。 tinycolor2 は、ユーザー入力シナリオ用の最も寛容なパーサーです。
  • ゼロ依存ユーティリティは、構文解析、明るくしたり、暗くしたり、コントラストをチェックするだけで済むアプリケーションに実行可能です。完全な実装は100行未満です。
  • 計算された色を積極的にキャッシュし、レンダリングごとに再計算するのではなく、結果をCSSカスタムプロパティに書き込みます。
  • 色コンバーターを使用して色変換を視覚的に確認し、シェードジェネレーターを使用して単一のブランドヘックスから完全な50–950 Tailwind互換スケールを生成します。

関連カラー

関連ブランド

関連ツール