Reactで、選んだ色から補色や類似色の配色を作る方法
色の 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 以外の色空間で色相を回す方法は試していません。作った配色が見た目に調和しているかどうかの評価も、この記事の範囲の外です。

配色は、色相を何度回すかで決まる
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 と同じ式です。
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 にしました。
#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)は計算せず、そのまま返します。
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 のコントラスト比が高いほうを、色見本ごとに選びます。
// 色見本の上の文字を、白と濃い色のどちらにするか(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、配色の種類を選ぶラジオボタン、色見本の一覧を並べます。
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 倍して丸めて比べています。結果の一部を抜き出します。
比べた組み合わせ: 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 で計算した値を使うと、表示した文字と色見本の色がそろいます。





