Figma で作った色を CSS に持っていくとき、HEX で欲しいのか rgb() なのか hsl() なのかは場面による。3形式を相互変換できるカラーピッカーは定番ツールだが、自分で作ると「色空間変換の数式」と「3つの入力をどう同期させるか」という2つの地味な課題が出てくる。
HEX・RGB・HSL を相互変換しつつスライダーで色を作れるツールをぱんだツールズの1機能として作った。ライブラリは使わず、変換は全部自前。
この記事では、RGB↔HSL の変換数式と、3形式の入力を破綻なく同期させる「state を1つに絞る」設計を解説する。
HEX ↔ RGB は16進数の読み書きだけ
一番簡単なのが HEX と RGB の相互変換。HEX(#RRGGBB)は各2桁が R/G/B の16進数なので、切り出して parseInt(_, 16) するだけ。
function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
const m = hex.replace('#', '').match(/^([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i)
return m ? { r: parseInt(m[1], 16), g: parseInt(m[2], 16), b: parseInt(m[3], 16) } : null
}
function rgbToHex(r: number, g: number, b: number): string {
return '#' + [r, g, b].map((v) => v.toString(16).padStart(2, '0')).join('')
}
hexToRgb は正規表現でちゃんと6桁16進かを検証し、ダメなら null を返す(入力途中の不正な文字列をはじくため)。#fff のような3桁ショートハンドは対象外で、6桁の #RRGGBB のみ受け付ける割り切り。rgbToHex で忘れがちなのが padStart(2, '0')。0 を toString(16) すると "0" の1桁になるので、2桁に詰めないと #000 のように桁が崩れる。
RGB → HSL:最大・最小から色相を求める
HSL(色相・彩度・明度)への変換はもう少し数式が要る。まず RGB を 0〜1 に正規化し、最大値と最小値を取る。明度 L は単純に最大と最小の中点。
function rgbToHsl(r: number, g: number, b: number) {
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
// 無彩色(R=G=B)は色相・彩度を定義できないので 0 にする
if (max === min) return { h: 0, s: 0, l: Math.round(l * 100) }
const d = max - min
// 彩度 S は明度が 0.5 を境に分母が変わる
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
// 色相 H は「どのチャンネルが最大か」で式を切り替える
let h = 0
if (max === rn) h = ((gn - bn) / d + (gn < bn ? 6 : 0)) / 6
else if (max === gn) h = ((bn - rn) / d + 2) / 6
else h = ((rn - gn) / d + 4) / 6
return { h: Math.round(h * 360), s: Math.round(s * 100), l: Math.round(l * 100) }
}
ポイントが3つある。
無彩色の特別扱い。 max === min(R=G=B、つまりグレー)のときは色相も彩度も意味を持たない(d が0で割り算が壊れる)ので、h=0, s=0 で早期 return する。
彩度の分母が明度で変わる。 同じ「鮮やかさ」でも、明るい色と暗い色では計算式の分母が違う。l > 0.5 で 2 - max - min、それ以外で max + min を使う。HSL の定義そのもの。
色相は最大チャンネルで場合分け。 赤が最大なら緑と青の差、緑が最大なら青と赤の差、という具合に、どの原色が支配的かで基準が変わる。赤が最大かつ緑<青のときだけ +6 するのは、色相環をぐるっと回って負にならないようにするため。
HSL → RGB:色相環を3分割する hue2rgb
逆方向はもっとトリッキー。色相(角度)から RGB を復元するには、補助関数 hue2rgb で「色相環のどの位置にいるか」を区間ごとに線形補間する。
function hslToRgb(h: number, s: number, l: number) {
const hn = h / 360, sn = s / 100, ln = l / 100
// 彩度0は完全な無彩色。L をそのまま 3チャンネルに
if (sn === 0) {
const v = Math.round(ln * 255)
return { r: v, g: v, b: v }
}
const q = ln < 0.5 ? ln * (1 + sn) : ln + sn - ln * sn
const p = 2 * ln - q
const hue2rgb = (t: number) => {
if (t < 0) t += 1; if (t > 1) t -= 1 // 色相環なので 0〜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(hn + 1 / 3) * 255), // R は色相を +1/3 ずらす
g: Math.round(hue2rgb(hn) * 255),
b: Math.round(hue2rgb(hn - 1 / 3) * 255), // B は -1/3
}
}
p と q は明度・彩度から決まる下限・上限の値で、hue2rgb がその間を色相位置に応じて補間する。R/G/B はそれぞれ色相環上で 120 度(= 1/3)ずつずれた位置をサンプリングするので、hn に +1/3・0・-1/3 を与えている。t が範囲外になったら +1/-1 で巻き戻すのは、色相が円環状(360度で一周)だから。
このあたりは HSL の標準的な変換アルゴリズムそのままで、CSS の hsl() と同じ結果になる。
設計の肝:state は HEX 1つだけ、RGB と HSL は「導出」する
数式以上に効くのが、3形式をどう同期させるかという設計。HEX・RGB・HSL それぞれを独立した state にすると、片方を変えたら他2つを更新する処理が3×2方向で必要になり、丸め誤差や更新漏れですぐ食い違う。
このツールは state を hex 1つだけにした。RGB と HSL は毎レンダリングで hex から導出する。
const [hex, setHex] = useState('#3b82f6')
// RGB・HSL は state ではなく hex からの「導出値」
const rgb = hexToRgb(hex) ?? { r: 59, g: 130, b: 246 }
const hsl = rgbToHsl(rgb.r, rgb.g, rgb.b)
入力がどの形式であっても、最終的に hex を更新するだけ。RGB スライダーをいじったら RGB→HEX に変換してから setHex、HSL スライダーなら HSL→RGB→HEX と変換してから setHex する。
function updateFromRgb(key: 'r' | 'g' | 'b', raw: string) {
const v = clamp(parseInt(raw) || 0, 0, 255)
const next = { ...rgb, [key]: v }
setHex(rgbToHex(next.r, next.g, next.b)) // 結局 hex を更新するだけ
}
function updateFromHsl(key: 'h' | 's' | 'l', raw: string) {
const max = key === 'h' ? 360 : 100
const v = clamp(parseInt(raw) || 0, 0, max)
const next = { ...hsl, [key]: v }
const nextRgb = hslToRgb(next.h, next.s, next.l)
setHex(rgbToHex(nextRgb.r, nextRgb.g, nextRgb.b)) // HSL→RGB→HEX で hex に集約
}
これが単一の真実源(single source of truth)。表示される RGB・HSL は常に hex から再計算されるので、3形式が食い違う余地がそもそもない。「複数の state を相互に同期させる」という最もバグりやすいパターンを、「state を1つに絞って残りは導出」に置き換えることで消している。
副作用として、HSL は丸め(Math.round)を挟むので、HSL スライダーを動かして戻すと元の値とわずかにズレることがある。ただし HEX を真実源にしている分、表示の一貫性は崩れない。色の同期 UI では「全部 state にしない」が効くという好例。
まとめ
カラーピッカーは「変換数式」と「state 設計」の2本立てだった。
- HEX↔RGB は16進の読み書き。
rgbToHexはpadStart(2, '0')を忘れると桁が崩れる - RGB→HSL は正規化して max/min から L を出し、無彩色を特別扱い、彩度の分母は L で切り替え、色相は最大チャンネルで場合分け
- HSL→RGB は
p/qとhue2rgbで色相環を区間補間。R/G/B は ±1/3 ずらしてサンプリング -
state は
hex1つだけ、RGB/HSL は毎回導出。単一の真実源にすれば3形式の同期バグが原理的に起きない
数式は標準アルゴリズムを写すだけだが、「3つの値を同期させない(1つから導出する)」という設計判断の方が、実は再利用の効くテクニックだった。
ぱんだツールズ では他にも PDF・画像・CSV・テキスト処理など、開発者向けのツールを多数公開している。全部無料・登録不要・ブラウザ完結で使える。
https://sakutto-panda.com
この記事は Zenn にも同じ内容を投稿しています。