Reactで、選んだ色に近いCSSの色の名前を表示する方法

​​React​CSS,Oklab,TypeScript

色の input で選んだ色に、royalblue や crimson のような CSS の色の名前のうち、いちばん近いものを表示する React の部品を作りました。候補は CSS の仕様にある148個の名前です。選んだ色と名前の色を Oklab という色空間の値に直し、仕様のサンプルコードにある ΔEOK2 で距離を測って、いちばん近いものを選びます。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153(headless、Playwright 1.63.0 から起動)です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2 です。.tsx を tsc で型チェックしてから esbuild で変換し、開発用ビルドを StrictMode 付きで動かしました。Firefox と Safari、スマートフォンの実機、本番用ビルド、サーバーでの描画(SSR)は試していません。選ばれた名前が人の目で見ていちばん近いかどうかを、人に見てもらって比べることもしていません。

候補にする名前は、CSS の仕様にある148個

CSS で red や lightseagreen のように書ける色の名前は、CSS Color Module Level 4 の named colors の節に、16進数の値と一緒に表で載っています。transparent と currentcolor は別の節で決められていて、この表には入っていません。

この表の名前と値を、そのまま TypeScript の配列にしました。仕様の原稿から取り出して書き出したもので、表と同じ148組です。

ts
// CSS Color Module Level 4 の named colors(transparent を除く)
export const NAMED_COLORS: readonly [name: string, hex: string][] = [
  ['aliceblue', '#f0f8ff'], ['antiquewhite', '#faebd7'], ['aqua', '#00ffff'],
  ['aquamarine', '#7fffd4'], ['azure', '#f0ffff'], ['beige', '#f5f5dc'],
  // …(148組。途中は省略)
  ['white', '#ffffff'], ['whitesmoke', '#f5f5f5'], ['yellow', '#ffff00'],
  ['yellowgreen', '#9acd32'],
];

検証環境の Chromium で、148個の名前を1つずつ color に入れて getComputedStyle() で読み出すと、どれも表の16進数と同じ rgb() になりました。名前は大文字と小文字を区別しないと仕様に書かれていて、RoyalBlue と書いても rgb(65, 105, 225) と読まれました。

148個のうち、MDN の named-color のページにあるとおり、gray と grey、aqua と cyan、fuchsia と magenta などは同じ色の別名です。同じ値をまとめると、値は139通りになりました。この記事のコードでは、同じ値の名前を1つにまとめて「gray / grey」のように並べて表示します。

なお、仕様は、名前から色を思い浮かべにくい、名前の付け方に一貫性がない(たとえば darkgray は gray より明るい)などの理由で、名前を使うことを勧めていません。この記事では、CSS を書くためではなく、選んだ色の呼び名の目安として表示するのに使います。

色を Oklab に直して、ΔEOK2 で距離を測る

仕様の「色の差の式」の説明によると、色の差の式は、2つの色がどのくらい違って見えるかを見積もるものです。仕様の Oklab の節には、Oklab は CIE Lab より見え方のむらが少ないので、色の差は3次元の空間での直線距離(ユークリッド距離)で求められる、と書かれています。この距離を ΔEOK と呼びます。

さらに、仕様の ΔEOK2 の節によると、ΔEOK は明るさの差にくらべて鮮やかさの差を小さく見積もります。そこで a と b(色みを表す2つの軸)の差を2倍してから距離をとる ΔEOK2 が示されていて、これで予測の精度が大きく上がったとされています。この記事では ΔEOK2 を使いました。

16進数から Oklab への変換は、仕様の色の変換のサンプルコードの手順と係数をそのまま使い、TypeScript の関数にまとめました。sRGB の値をガンマのかからない値に戻し、XYZ、LMS を経て Oklab にします。

ts
export type Oklab = readonly [L: number, a: number, b: number];

// '#rrggbb' を 0〜1 の sRGB の3つの値にする
export function hexToRgb(hex: string): [number, number, number] {
  const n = parseInt(hex.slice(1), 16);
  return [(n >> 16) / 255, ((n >> 8) & 0xff) / 255, (n & 0xff) / 255];
}

// ガンマのかかった sRGB の値を、光の強さに比例する値(linear)に戻す
function toLinear(v: number): number {
  return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
}

// sRGB → Oklab。係数は CSS Color 4 のサンプルコード(conversions.js)の値
export function hexToOklab(hex: string): Oklab {
  const [r, g, b] = hexToRgb(hex).map(toLinear);
  // linear sRGB → XYZ(D65)
  const x = (506752 / 1228815) * r + (87881 / 245763) * g + (12673 / 70218) * b;
  const y = (87098 / 409605) * r + (175762 / 245763) * g + (12673 / 175545) * b;
  const z = (7918 / 409605) * r + (87881 / 737289) * g + (1001167 / 1053270) * b;
  // XYZ → LMS → 立方根 → Oklab
  const l = Math.cbrt(0.819022437996703 * x + 0.3619062600528904 * y - 0.1288737815209879 * z);
  const m = Math.cbrt(0.0329836539323885 * x + 0.9292868615863434 * y + 0.0361446663506424 * z);
  const s = Math.cbrt(0.0481771893596242 * x + 0.2642395317527308 * y + 0.6335478284694309 * z);
  return [
    0.210454268309314 * l + 0.7936177747023054 * m - 0.0040720430116193 * s,
    1.9779985324311684 * l - 2.4285922420485799 * m + 0.450593709617411 * s,
    0.0259040424655478 * l + 0.7827717124575296 * m - 0.8086757549230774 * s,
  ];
}

// ΔEOK2:Oklab の a と b の差を2倍してから、まっすぐな距離をとる
export function deltaEOK2(p: Oklab, q: Oklab): number {
  return Math.hypot(p[0] - q[0], 2 * (p[1] - q[1]), 2 * (p[2] - q[2]));
}

変換が合っているかは、ブラウザ自身の変換と比べて確かめました。CSS の相対色の構文で oklab(from #4169e1 l a b) のように書くと、ブラウザが元の色を Oklab に直した値を getComputedStyle() で読み出せます。148個の色すべてで比べると、hexToOklab の結果との差は最大でも0.00005でした(yellow の a の値)。

いちばん近い名前を探す関数

nearestColorName は、139通りの色すべてとの ΔEOK2 を測り、いちばん小さいものを返します。名前の色の Oklab の値は、モジュールを読み込んだときに1回だけ計算しておきます。

ts
import { NAMED_COLORS } from './namedColors';
import { deltaEOK2, hexToOklab, type Oklab } from './oklab';

export type ColorName = { names: string[]; hex: string; lab: Oklab };

// 同じ値の名前(gray と grey など)を1つにまとめ、Oklab の値を先に計算しておく
const PALETTE: ColorName[] = [];
for (const [name, hex] of NAMED_COLORS) {
  const same = PALETTE.find((c) => c.hex === hex);
  if (same) same.names.push(name);
  else PALETTE.push({ names: [name], hex, lab: hexToOklab(hex) });
}

export type Nearest = ColorName & { distance: number };

// hex にいちばん近い名前付きの色を返す
export function nearestColorName(hex: string): Nearest {
  const lab = hexToOklab(hex);
  let best = PALETTE[0];
  let bestDistance = Infinity;
  for (const c of PALETTE) {
    const d = deltaEOK2(lab, c.lab);
    if (d < bestDistance) {
      best = c;
      bestDistance = d;
    }
  }
  return { ...best, distance: bestDistance };
}

全部と比べる単純な探し方ですが、候補は139通りしかありません。検証環境の Chromium で nearestColorName を1万回呼ぶと50ミリ秒で、1回あたり約5マイクロ秒でした。この速さなので、次の部品では、選んだ色が変わったときに useMemo の中で毎回探しています。

色の input と並べて表示する部品

部品には、色の input、選んだ色と名前の色の2つの色見本、結果の文を並べます。

tsx
import { useId, useMemo, useState } from 'react';
import { nearestColorName } from './nearestColor';
import { hexToOklab } from './oklab';

// 明るい色の上は濃い文字、暗い色の上は白い文字にする
const textOn = (hex: string) => (hexToOklab(hex)[0] > 0.65 ? '#1f2a30' : '#ffffff');

export function ColorNameFinder() {
  const [hex, setHex] = useState('#3a7bd5');
  const nearest = useMemo(() => nearestColorName(hex), [hex]);
  const id = useId();
  const exact = nearest.hex === hex;

  return (
    <div className="finder">
      <label htmlFor={id}>色を選ぶ</label>
      <input id={id} type="color" value={hex} onChange={(e) => setHex(e.target.value)} />
      <div className="swatches" aria-hidden="true">
        <span style={{ background: hex, color: textOn(hex) }}>選んだ色<br />{hex}</span>
        <span style={{ background: nearest.hex, color: textOn(nearest.hex) }}>
          {nearest.names[0]}<br />{nearest.hex}
        </span>
      </div>
      <output htmlFor={id} aria-live="polite">
        {exact ? 'ちょうど ' : 'いちばん近いのは '}
        <strong>{nearest.names.join(' / ')}</strong>
        {exact ? ' です' : ` です(差 ${nearest.distance.toFixed(3)})`}
      </output>
    </div>
  );
}

色見本は見た目のためのものなので aria-hidden で読み上げから外し、名前は <output> の文で伝えます。色見本の文字の色は、Oklab の明るさ(L)が0.65を超える色なら濃い色、それ以下なら白にしました。

名前とぴったり同じ色かどうかは、nearest.hex === hex で文字列として比べています。名前の表の16進数はすべて小文字です。検証環境の Chromium では、色の input に Playwright で #CC3366 と大文字で入れても、value は小文字の #cc3366 で読み出されたので、この比べ方で一致を判定できました。

色を選ぶ入力欄で #cc3366 の色が選ばれていて、その下に左は「選んだ色 #cc3366」、右は「crimson #dc143c」の色見本が並び、下に「いちばん近いのは crimson です(差 0.119)」と表示されている
#cc3366 を選んだときの表示(検証環境の Chromium、幅 375px)

検証環境の Chromium で、Playwright から色の input に値を入れたときの表示は次のとおりです。

選んだ色 表示された文
#3a7bd5(最初の値) いちばん近いのは royalblue です(差 0.092)
#cc3366 いちばん近いのは crimson です(差 0.119)
#cc9966 いちばん近いのは peru です(差 0.077)
#e0e0e0 いちばん近いのは gainsboro です(差 0.012)
#808080 ちょうど gray / grey です
#ff0000 ちょうど red です

「差」の数字は ΔEOK2 の値で、0に近いほど名前の色に近いことを表します。同じ値の名前を1つにまとめたので、#808080 では gray と grey の両方が出ました。

RGB の値のまま距離を測ると、選ばれる名前が変わる

Oklab に直さず、RGB の0〜255の値のまま距離を測っても、いちばん近い名前は選べます。そこで、R・G・B をそれぞれ16段階(0、17、34、…、255)に変えた4096色について、2つの方法で選ばれる色を比べました。違う色が選ばれたのは1,517色で、全体の37.0%でした。

a と b を2倍しない ΔEOK とも比べると、ΔEOK2 と違う色が選ばれたのは1,035色(25.3%)でした。

3段の色見本。1段目は #cc3366 に対して ΔEOK2 では crimson、RGB の距離では indianred。2段目は #cc9966 に対して ΔEOK2 では peru、RGB の距離では darkkhaki。3段目は #003300 に対して ΔEOK2 では darkgreen、ΔEOK では darkslategray が選ばれている
距離の測り方によって、選ばれる名前が変わった3つの色

たとえば #cc3366 は、ΔEOK2 では crimson ですが、RGB の距離では indianred になりました。#cc9966 は、ΔEOK2 では peru、RGB の距離では darkkhaki です。暗い緑の #003300 は、ΔEOK2 と RGB の距離ではどちらも darkgreen ですが、ΔEOK では灰色がかった darkslategray になりました。

どの名前を「近い」とするかは、距離の測り方しだいで変わります。仕様には、ΔEOK2 のほかに、明るさの軸に手を加えた ΔEOKr2 や、式の込み入った ΔE2000 も載っています。また、仕様は、ΔEOKr2 のほうが ΔE2000 とよく合うとして、処理の速さが大事な実装には ΔEOKr2 を勧めています。この記事では、式がより簡単な ΔEOK2 を使いました。

​Posted by 86