Reactで、選んだ色から補色や類似色の配色を作る方法

​​React​CSS,HSL,Playwright,TypeScript

色の input で選んだ色から、補色や類似色などの配色を作り、色見本として並べる React のコンポーネントを作ります。色を HSL(色相・彩度・明度)に直し、色相だけを決まった角度で回してから、16進数のカラーコードに戻します。配色の種類は、補色、類似色、トライアド、スプリット・コンプリメンタリーの4つです。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、本番ビルドと、StrictMode を付けた開発ビルドの両方を Playwright で操作しました。どちらも同じ表示になり、React の警告やエラーは出ませんでした。色の input は Playwright の fill() で値を入れており、パレットの画面を開いて操作してはいません。Firefox と Safari、OKLCH など HSL 以外の色空間で色相を回す方法は試していません。作った配色が見た目に調和しているかどうかの評価も、この記事の範囲の外です。

配色の種類を類似色、トライアド、スプリット・コンプリメンタリーの順に選ぶと、色見本が #00e894・#00c8e8・#0054e8、#00c8e8・#e800c8・#c8e800、#00c8e8・#e80054・#e89400 と変わる。最後に元の色を #d85878 にすると、色見本は #d85878・#58d878・#58b8d8 になる
配色の種類を3回切り替えてから、元の色を変えたときの表示の変化。カーソル、押した場所の輪、右下の操作の回数は GIF 用に重ねたもの。変わった色見本と元の色の表示は、変わった瞬間だけピンクの枠や背景にしている。元の色は、色の input にパレットを開かずに値を入れて変えている

配色は、色相を何度回すかで決まる

HSL の色相は、色を円に並べたときの角度です。配色の種類ごとに「元の色から何度回した色を並べるか」を決めておけば、どの配色も同じ関数で作れます。この記事では、次の角度にしました。

配色の種類 回す角度 元の色が #00c8e8 のとき
補色 0°、180° #00c8e8 #e82000
類似色 −30°、0°、+30° #00e894 #00c8e8 #0054e8
トライアド 0°、120°、240° #00c8e8 #e800c8 #c8e800
スプリット・コンプリメンタリー 0°、150°、210° #00c8e8 #e80054 #e89400

補色は色相環の反対側、トライアドは円を3等分した位置の色です。MDN の相対色構文の解説にある配色の例でも、補色を「色相を +180 度した色」、トライアドを「−120 度と +120 度した色」として作っています(MDN の例は、HSL ではなく LCH の色相を回しています)。類似色は元の色から ±30° 回した両隣の色、スプリット・コンプリメンタリーは補色の両隣(180° ± 30°)にしました。角度は配列にしてあるので、好みに合わせて変えられます。

16進数と HSL を行き来する関数

MDN の <input type="color"> の解説では、入力された色が小文字7文字の16進数表記に変換できないと、値が無効になると説明されています。検証環境で大文字の #D85878 を入れたところ、value は小文字の #d85878 で読めました。これを HSL に直す関数と、HSL から戻す関数を書きます。HSL から RGB に戻す計算は、CSS Color Module Level 4 のHSL から sRGB への変換に載っているサンプルの JavaScript と同じ式です。

ts
export type HSL = { h: number; s: number; l: number }; // h: 0〜360未満、s と l: 0〜100

// 「#rrggbb」を HSL にする。丸めずに小数のまま返す
export function hexToHsl(hex: string): HSL {
  const n = parseInt(hex.slice(1), 16);
  const r = ((n >> 16) & 255) / 255;
  const g = ((n >> 8) & 255) / 255;
  const b = (n & 255) / 255;
  const max = Math.max(r, g, b);
  const min = Math.min(r, g, b);
  const l = (max + min) / 2;
  const d = max - min;
  if (d === 0) return { h: 0, s: 0, l: l * 100 }; // 灰色・白・黒には色相がない
  const s = d / (1 - Math.abs(2 * l - 1));
  let h: number;
  if (max === r) h = ((g - b) / d) % 6;
  else if (max === g) h = (b - r) / d + 2;
  else h = (r - g) / d + 4;
  return { h: normalizeHue(h * 60), s: s * 100, l: l * 100 };
}

// HSL を、色の input と同じ小文字の「#rrggbb」にする
export function hslToHex({ h, s, l }: HSL): string {
  const sat = s / 100;
  const lig = l / 100;
  const f = (k: number) => {
    const t = (k + h / 30) % 12;
    const v = lig - sat * Math.min(lig, 1 - lig) * Math.max(-1, Math.min(t - 3, 9 - t, 1));
    return Math.round(v * 255).toString(16).padStart(2, '0');
  };
  return `#${f(0)}${f(8)}${f(4)}`;
}

// -30 や 390 も、0〜360未満にそろえる
export function normalizeHue(h: number): number {
  return ((h % 360) + 360) % 360;
}

検証環境で、これらの関数を呼んだ結果です(一部を抜き出しています)。#ff0000 の色相は 0、#00ff00 は 120、#0000ff は 240 になりました。normalizeHue は、−30 を 330 に、390 を 30 に、720 を 0 にしました。

text
#ff0000 → h=0 s=100 l=50
#00ff00 → h=120 s=100 l=50
#0000ff → h=240 s=100 l=50
#00c8e8 → h=188.2759 s=100 l=45.4902
#808080 → h=0 s=0 l=50.1961

HSL は、整数に丸めずに持つ

hexToHsl は、計算した値を丸めずに返します。HSL を hsl(188 100% 45%) のような整数にしてから戻すと、元の色にならないことが多いためです。

検証環境で、#000000 から #ffffff までの 16,777,216 色すべてを HSL に直して戻しました。小数のまま戻したときは、すべての色が元の色に戻りました。色相・彩度・明度を整数に丸めてから戻すと、15,001,676 色(89.4%)が元の色と違う色になりました。たとえば #000003 は hsl(240 100% 1%) になり、戻すと #000005 です。

配色の色は、元の色の色相に角度を足して、そこで初めて16進数に丸めます。同じく全色で、補色の補色を作ると元の色に戻ることも確かめました(元に戻らない色は1色もありませんでした)。

角度を足して、配色の色を作る

配色の種類ごとの角度の一覧 HARMONIES と、配色を作る関数 makeHarmony です。元の色(角度 0)は計算せず、そのまま返します。

ts
export const HARMONIES = [
  { id: 'complementary', name: '補色', offsets: [0, 180] },
  { id: 'analogous', name: '類似色', offsets: [-30, 0, 30] },
  { id: 'triadic', name: 'トライアド', offsets: [0, 120, 240] },
  { id: 'split', name: 'スプリット・コンプリメンタリー', offsets: [0, 150, 210] },
] as const;

export type HarmonyId = (typeof HARMONIES)[number]['id'];

// 元の色の色相だけを回して、彩度と明度はそのままにする
export function makeHarmony(base: string, offsets: readonly number[]): string[] {
  const hsl = hexToHsl(base);
  return offsets.map((o) => (o === 0 ? base : hslToHex({ ...hsl, h: normalizeHue(hsl.h + o) })));
}

灰色・白・黒は、どの配色でも同じ色になる

彩度が 0 の色(灰色・白・黒)には色相がありません。CSS Color Module Level 4 でも、hsl() の彩度が 0% のときは、色相をどの角度にしても同じ色になると書かれています(仕様にある、彩度が 0% のときの色相の扱い)。hexToHsl は、このとき色相を 0 にしています。

検証環境で #808080、#ffffff、#000000 の補色を作ると、どれも元の色と同じになりました。そこでコンポーネントでは、彩度が 0 のときに「灰色・白・黒には色相がないため、どの配色でも同じ色になります。」という案内を出します。

色見本の文字は、明るさを見て白か濃い色にする

色相だけを回した色は、HSL の明度が同じでも、相対輝度は大きく変わります。#00c8e8 のトライアドの3色は、HSL の明度がどれも 45.49 ですが、WCAG の相対輝度を計算すると、次のように違いました。

色 HSL の明度 相対輝度 色見本の文字
#00c8e8 45.49 0.471 濃い色(#1f2a30)
#e800c8 45.49 0.213 白
#c8e800 45.49 0.700 濃い色(#1f2a30)

色見本の上にカラーコードを書くとき、文字色を1つに決めると、読みにくい色見本が出てきます。そこで、白と #1f2a30 のうち、WCAG のコントラスト比が高いほうを、色見本ごとに選びます。

ts
// 色見本の上の文字を、白と濃い色のどちらにするか(WCAG のコントラスト比が高いほう)
export function luminance(hex: string): number {
  const n = parseInt(hex.slice(1), 16);
  const ch = (v: number) => {
    const c = v / 255;
    return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
  };
  return 0.2126 * ch((n >> 16) & 255) + 0.7152 * ch((n >> 8) & 255) + 0.0722 * ch(n & 255);
}

export function textColorOn(bg: string): '#ffffff' | '#1f2a30' {
  const lb = luminance(bg);
  const white = 1.05 / (lb + 0.05);
  const dark = (lb + 0.05) / (luminance('#1f2a30') + 0.05);
  return white >= dark ? '#ffffff' : '#1f2a30';
}

配色を表示するコンポーネント

元の色の input、配色の種類を選ぶラジオボタン、色見本の一覧を並べます。

tsx
import { useId, useState } from 'react';
import { HARMONIES, type HarmonyId, hexToHsl, makeHarmony, textColorOn } from './harmony';

export function ColorHarmony() {
  const [base, setBase] = useState('#00c8e8');
  const [kind, setKind] = useState<HarmonyId>('complementary');
  const inputId = useId();

  const harmony = HARMONIES.find((h) => h.id === kind)!;
  const colors = makeHarmony(base, harmony.offsets);
  const achromatic = hexToHsl(base).s === 0;

  return (
    <section className="harmony">
      <div className="base">
        <label htmlFor={inputId}>元の色</label>
        <input id={inputId} type="color" value={base} onChange={(e) => setBase(e.target.value)} />
        <output htmlFor={inputId}>{base}</output>
      </div>

      <fieldset>
        <legend>配色の種類</legend>
        {HARMONIES.map((h) => (
          <label key={h.id}>
            <input
              type="radio"
              name="harmony"
              value={h.id}
              checked={kind === h.id}
              onChange={() => setKind(h.id)}
            />
            {h.name}
          </label>
        ))}
      </fieldset>

      {achromatic && (
        <p className="note">灰色・白・黒には色相がないため、どの配色でも同じ色になります。</p>
      )}

      <ul className="swatches" aria-label={`${harmony.name}の配色`}>
        {colors.map((c, i) => {
          const o = harmony.offsets[i];
          return (
            <li key={o} style={{ backgroundColor: c, color: textColorOn(c) }}>
              <span className="offset">{o === 0 ? '元の色' : `${o > 0 ? '+' : ''}${o}°`}</span>
              <span className="hex">{c}</span>
            </li>
          );
        })}
      </ul>
    </section>
  );
}

「元の色」のラベル、色の input、<output> は、useId で作った id で結び付けています。配色の種類は、fieldset と legend でまとめたラジオボタンです。色見本の一覧には aria-label で「補色の配色」のような名前を付けました。

検証環境で本番ビルドを操作したところ、色見本は最初の表と同じ色になり、トライアドの文字色も相対輝度の表のとおりでした。元の色を #ff0000 にすると補色は #00ffff になり、#808080 にすると2つの色見本がどちらも #808080 になって、案内の文が出ました。Playwright で読み取ったアクセシビリティツリーでは、ラジオボタンは「配色の種類」という group の中に並び、色見本は「補色の配色」という list の listitem になっていました。

CSS の相対色構文でも、同じ配色が作れる

色見本の背景を塗るだけなら、CSS の相対色構文でも作れます。hsl(from #00c8e8 calc(h + 180) s l) と書くと、#00c8e8 の色相を 180 度回した色になります(MDN の hsl() の解説)。

検証環境の Chromium で、2,000 色と7つの角度(−30°、30°、120°、150°、180°、210°、240°)の組み合わせ 14,000 通りについて、相対色構文で指定した色を getComputedStyle で読み取り、makeHarmony の結果と比べました。計算値は color(srgb 0.909804 0.12549 0) のように 0〜1 の小数で返ってきたので、255 倍して丸めて比べています。結果の一部を抜き出します。

text
比べた組み合わせ: 14000、一致: 12057、不一致: 1943
   #2cfd62 -30: JS #5ffd2c / CSS #5efd2c (color(srgb 0.370588 0.992157 0.172549))
R・G・B の値のずれの最大: 1

R・G・B の値のずれは、いちばん大きくても 1 でした。#2cfd62 を −30° 回した色の R は、hslToHex の式では丸める前が 94.50000000000006 で、95(5f)になります。Chromium の計算値は 0.370588 で、255 倍すると 94.49994 になり、丸めると 94(5e)です。この例では、丸める前の値がちょうど 0.5 のあたりで、どちらに丸まるかが分かれていました。

画面に色を塗るだけなら CSS で十分です。カラーコードを文字で表示したり、コピーできるようにしたりするときは、この記事のように JavaScript で計算した値を使うと、表示した文字と色見本の色がそろいます。

​Posted by 86