カラーピッカー UI 9選 — ライブラリなしで作る色選択
カラーピッカー ui とは、画面上で色を選んで HEX コードとして返す選択 UI のことです。このページにはその部品が 9 種、ライブラリなしで色空間の計算まで手書きしたコードが実際に動くまま入っています。
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 彩度・明度の四角をドラッグして選ぶ色
- 02 角度を測って色相を選ぶカラーホイール
- 03 HEX・RGB・HSL の三つが連動する入力
- 04 市松模様の上で透明度を動かすアルファスライダー
- 05 最近選んだ色が八つ前に詰まる履歴
- 06 色相を回して作る補色・類似色・三色配色
- 07 二色のコントラスト比を測って AA・AAA を付ける判定
- 08 停止点をドラッグして作るグラデーション
- 09 絵の上のピクセルを拾うスポイト
9 種は見た目の順ではなく、色がピッカーの中で働く順に並べています。フィールドの上で色を選ぶ 2 軸(01・02)から始まり、表記のやり取り(03)、透明度(04)、選んだ色の蓄積(05)、1 つの色相が配色へ広がる(06)、2 色が出会う基準を測る(07)、色がグラデーションへつながる(08)、最後に画面から色を拾う(09)です。9 種が同じトークンを分け合っているので、1 つのページに何枚か混ぜてもトーンがぶれません。どれも値を JS で文字として書かず、登録プロパティ 1 つから色と数字を同時に取り出す作りで統一してあるため、必要な 1 枚だけを借りても中の構造は変わりません。色を選ぶ UI は数字がそのまま絵になる主題なので、チップの色と隣に書かれた HEX が 1 文字でも食い違うと、その時点でピッカーは信用できなくなります。だからこの 9 種は、見た目を整えるより先に「色と数字が絶対にずれない経路」を作ることを最優先にしています。
01彩度・明度の四角をドラッグして選ぶ色
色選びの基本軸です。四角の横が彩度、縦が明度として読まれ、ドラッグするとつまみの位置の HSV を RGB に変えて、チップと HEX の文字が同じ瞬間に変わります。累積変化面積は画面の 2.738% で、9 種の中でいちばん静かです。
// 登録プロパティ 3 つだけが色の唯一の源 — チップの塗りも HEX の文字もぜんぶここから出る
@property --r { syntax: "<integer>"; inherits: true; initial-value: 204; }
@property --g { syntax: "<integer>"; inherits: true; initial-value: 228; }
@property --b { syntax: "<integer>"; inherits: true; initial-value: 255; }
@counter-style hx {
system: numeric;
symbols: "0" "1" "2" "3" "4" "5" "6" "7" "8" "9" "A" "B" "C" "D" "E" "F";
pad: 2 "0"; // 0F のように前の桁を埋める
}
.pk__hex { counter-set: cr calc(var(--r)) cg calc(var(--g)) cb calc(var(--b)); }
.pk__hex::after { content: "#" counter(cr, hx) counter(cg, hx) counter(cb, hx); }
// HSV(0~360, 0~1, 0~1) → RGB(0~255)。6 区間を表に広げて分岐なしで選ぶ
function hsvToRgb(h, s, v) {
var c = v * s, x = c * (1 - Math.abs(((h / 60) % 2) - 1)), m = v - c;
var face = [[c, x, 0], [x, c, 0], [0, c, x], [0, x, c], [x, 0, c], [c, 0, x]][Math.floor(h / 60) % 6];
return face.map(function (n) { return Math.round((n + m) * 255); });
}
// マスの外へドラッグしてもこの要素がポインタを受け続ける — ドラッグが切れない要点の 1 行
el.setPointerCapture(e.pointerId);
02角度を測って色相を選ぶカラーホイール
色相だけを選ぶなら四角より円が速いです。円の中を押した位置の角度がそのまま 0〜360 の色相値になり、つまみはリング上の自分の色の位置に立ちます。動く値は --h ひとつだけなので、中心円の色と下の数字が食い違うことがありません。累積変化面積 5.619%、動いたフレーム 19/23 です。
// 中心からポインタまでの角度を測る。Math.atan2 は 3 時方向が 0 で時計回りに増え、
// conic-gradient(from 90deg) も赤(色相 0)を 3 時に置く — 基準が同じなので補正なしでそのまま色相値
var deg = Math.atan2(e.clientY - box.top - box.height / 2,
e.clientX - box.left - box.width / 2) * 180 / Math.PI;
hue = Math.round((deg + 360) % 360) % 360;
// つまみはリングに乗って回る要素 — 回転の中心が自分ではなくリングの中心なので、
// リング寸法のレイヤーごと回す。+90 は conic の from 90deg の補正
.hw__arm { transform: rotate(calc((var(--h) + 90) * 1deg)); }
.hw__arm::after {
transform: translate(-50%, -50%) translateY(-51px); // リング厚のまんなか(半径 51px)
}
.hw__core { background: hsl(var(--h) 92% 52%); }
03HEX・RGB・HSL の三つが連動する入力
同じ色を 3 つの表記でやり取りする入力です。どれかの欄に値を入れると残りの 2 つが同じ色に計算し直され、形式が崩れた欄だけが aria-invalid で赤く残ります。開発者ツールの色入力やデザイントークン編集に使う部品で、貼り付けたトークンの値が実際の画面でどの色になるかをその場で確かめられます。累積変化面積 6.699%・動いたフレーム 20/23 です。
// プレビュー文字 — 値は --r --g --b --hh --ss --ll の 6 つだけで、カウンタが 3 表記に分けて打つ。
// ループの根拠: 明度 l ≤ 0.5 の範囲では hsl(212, 90%, l) の RGB は (0.1l, 0.94l, 1.9l) × 255 と
// 明度に完全に比例する — 色相 212・彩度 90% を固定するので、どのフレームでも 2 つの表記が同じ色を指す
.sy.is-demo .sy__input { color: transparent; } // 本物の入力欄は空間を保ったまま隠す
.sy.is-demo .sy__ghost { opacity: 1; }
.sy__ghost--hex::after { content: "#" counter(cr, hx) counter(cg, hx) counter(cb, hx); }
.sy__ghost--rgb::after { content: "rgb(" counter(cr) ", " counter(cg) ", " counter(cb) ")"; }
.sy__ghost--hsl::after { content: "hsl(" counter(chh) ", " counter(css) "%, " counter(cll) "%)"; }
04市松模様の上で透明度を動かすアルファスライダー
透明な色は白い背景の上では見えないので、アルファスライダーは市松模様を敷くところから始まります。プレビューでは、色カバーの不透明度が 100%(完全な不透明)から 24% へ下がるにつれて後ろの市松模様が透けて見えてきます — カバーの不透明度、つまり模様を覆い隠す割合がそのままアルファ値です。半透明背景トークンやオーバーレイ色を目で合わせる部品で、9 種の中でいちばん広く動き、累積変化面積 13.06%・強度 87.5・動いたフレーム 18/23 です。
// 上の層は透明 → 不透明の色カバー、下の層は 12px タイルの市松模様(タイル内が 4 マスなので 1 マス 6px)
.al__track {
background-image:
linear-gradient(to right, rgba($subject-blue, 0), $subject-blue),
repeating-conic-gradient(#d8d4cc 0 25%, $stage-paper 0 50%);
background-size: auto, 12px 12px;
}
// つまみと数字はどちらも --a だけから出る — 264px トラックの 1% は 2.64px
.al__knob { transform: translateX(calc(var(--a) * 2.64px)) translate(-50%, -50%); }
.al__val::after { content: counter(ca) "%"; }
05最近選んだ色が八つ前に詰まる履歴
選んだ色をもう一度使えるようにする履歴です。追加を押すたびに先頭に 1 枠が割り込み、9 つ目は後ろへ押し出され、一覧は localStorage に残ってページを開き直してもそのままです。8 つのマスが 0.25 秒ずつ位相をずらして色を横へ流すことで、動いたフレーム 23/23 を満たしました。
// 一覧は localStorage に残る。ファイルとして開くと保存が blocked になることがあるので、
// 失敗したら基本パレットで続行する
function read() {
try {
var list = JSON.parse(window.localStorage.getItem(storeKey));
if (list && list.length) { return list.slice(0, 8); }
} catch (err) { /* 保存が使えない環境 */ }
return palette.slice();
}
recent.unshift(nowColor);
recent = recent.slice(0, 8);
// マスごとに animation-delay を -0.25s ずつ変える。8 色が 0.25 秒ごとに 1 マス横へ移ること自体が
// 「先頭に割り込んで後ろが詰まる」ことで、2 秒後ちょうど元の位置なのでテレポートがない
.rc.is-demo .rc__cell:nth-child(2) { animation-delay: -1.75s; }
@keyframes histRoll {
0% { --c: #{$subject-blue}; }
12.5% { --c: #{$subject-sky}; }
25% { --c: #{$subject-mint}; }
50% { --c: #{$stage-orange}; }
100% { --c: #{$subject-blue}; }
}
06色相を回して作る補色・類似色・三色配色
パレットの下書きです。基準の色相に補色は 180 度、類似色は ±30 度と ±60 度、三色は ±120 度を足して 5 枚のチップを作ります。色を 5 回計算せず、チップごとに「基準に足す角度」を 1 つだけ持たせて hsl() がその場で作るのが要点です。配色の切り替えの間を、チップが 40 ミリ秒ずつずれて順に跳ねて埋め、累積変化面積 9.759%・動いたフレーム 20/23 を満たしました。
// チップ 5 枚 — 塗りも角度の文字も同じ --oN から出る
.hm__chip:nth-child(1) .hm__sw { background: hsl(calc(var(--base) + var(--o1)) 68% 70%); }
.hm__chip:nth-child(1) .hm__deg { counter-set: cd calc(var(--o1)); }
.hm__deg::after { content: counter(cd) "°"; }
// 補色(0 0 0 180 180) → 類似色(-60 -30 0 30 60) → 三色(-120 -120 0 120 120)。
// 配色の交換は steps(1, end) で 1 フレームに切る — クロスフェードは半透明の残像を残す
@keyframes hmSet {
0% { --o1: 0; --o2: 0; --o3: 0; --o4: 180; --o5: 180; }
33% { --o1: -60; --o2: -30; --o3: 0; --o4: 30; --o5: 60; }
66% { --o1: -120; --o2: -120; --o3: 0; --o4: 120; --o5: 120; }
90%, 100% { --o1: 0; --o2: 0; --o3: 0; --o4: 180; --o5: 180; }
}
07二色のコントラスト比を測って AA・AAA を付ける判定
このサイトが毎回アクセシビリティの表を埋めるのに使っている式を、そのまま部品にしました。文字色と背景色の相対輝度をそれぞれ求めて比を出し、4.5 と 7 を超えた瞬間に AA・AAA のバッジが点きます。5 段階の背景を白い文字で測るプレビューは累積変化面積 12.05% で、9 種の中で 2 番目に広く動きます。
// WCAG 2.x の相対輝度 — チャネルごとにガンマを戻してから、人間の目の感度で重み付ける
function channel(c) { return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); }
function luminance(hex) {
var n = parseInt(hex.slice(1), 16);
return 0.2126 * channel(((n >> 16) & 255) / 255)
+ 0.7152 * channel(((n >> 8) & 255) / 255)
+ 0.0722 * channel((n & 255) / 255);
}
// 文字は白で固定なので、明るい側の輝度は常に 1
function ratioToWhite(hex) { return 1.05 / (luminance(hex) + 0.05); }
// 比の数字 — 整数部と小数 2 桁を別々に打って 4.53 の形を作る
@counter-style dd { system: numeric; symbols: "0" "1" "2" "3" "4" "5" "6" "7" "8" "9"; pad: 2 "0"; }
.cc__ratio { counter-set: cw calc(var(--rw)) cf calc(var(--rf)); }
.cc__ratio::after { content: counter(cw) "." counter(cf, dd) " : 1"; }
08停止点をドラッグして作るグラデーション
ヒーロー背景のグラデーションを目で合わせるビルダーです。バー上の停止点をドラッグすると位置がパーセントで読み取られ、その場で linear-gradient の文字列が下に現れて、そのままコピーできます。バーの色分布も、つまみの位置も、文字列も、--p ひとつから一緒に動きます。
// 下の CSS 文字列 — パーセントの席だけカウンタが埋める
.gs__bar {
background-image: linear-gradient(90deg, $stage-orange 0%, $stage-yellow calc(var(--p) * 1%), $subject-blue 100%);
}
.gs__stop--m { transform: translateX(calc(var(--p) * 2.64px)) translate(-50%, -50%); }
.gs__code { counter-set: cp calc(var(--p)); }
.gs__code::after {
content: "linear-gradient(90deg, #{$stage-orange} 0%, #{$stage-yellow} " counter(cp) "%, #{$subject-blue} 100%)";
}
09絵の上のピクセルを拾うスポイト
写真やスクリーンショットから色を抜く軸です。キャンバスに描いた絵の上を通ると、その位置のピクセル 1 つを getImageData で読んで、ルーペと HEX にそのまま映します。拾うボタンは EyeDropper API があれば画面全体から色を拾い、なければキャンバスの上で拾うモードへ静かに切り替わります。
// 上の 56px は横 3 色の帯 — チャネルごとに 1 次補間なので x 座標と色が正確に対応する
var band = ctx.createLinearGradient(0, 0, 264, 0);
band.addColorStop(0, '#ff4d1f');
band.addColorStop(0.5, '#ffd23f');
band.addColorStop(1, '#2f6df6');
var px = ctx.getImageData(sx, sy, 1, 1).data; // その 1 点の RGBA
// Chrome 系は画面のどこからでも拾う。ないブラウザはキャンバスの上で拾うへ静かに下がる
if ('EyeDropper' in window) {
new window.EyeDropper().open().then(function (res) {
var n = parseInt(res.sRGBHex.slice(1), 16);
setColor((n >> 16) & 255, (n >> 8) & 255, n & 255);
}).catch(function () { sample(); });
} else {
sample();
}
どこで壊れるか — 落とし穴
最初にぶつかったのは、色と文字が別々に動いてしまったことです。初版の 01 はチップの塗りを CSS が、HEX の文字を JS の textContent が別々に書く作りでした。プレビューのループで、色はすでに変わったのに文字が 1 拍遅れるフレームが出ました — 2 つの経路が分かれた瞬間、ピッカーはどれだけ見た目が良くても信頼できなくなります。9 種すべて、色と数字を登録プロパティ 1 つにだけ持たせ、counter-set がその値をカウンタへ運んで打つ形に直しました。文字を書く JS のコードが消えたので、食い違う経路自体がなくなりました。
2 番目は補間の基準でした。03 の明度のループを最初は $ease-spring で回したところ、イージング曲線は位置と色に同じ比率で効かないため、RGB の行と HSL の行が違う色を指すフレームが出ました。色空間とイージングが正確に対応するのは線形補間の上だけです — そこで 03 は RGB が明度に完全に比例する区間だけを通るように色相と彩度を固定し、イージングを linear に統一しました。02 のつまみも同じ話です。conic-gradient(from 90deg) は赤を 3 時に置き、Math.atan2 も 0 を 3 時に置きます — 2 つの基準が同じだと確認して初めて、角度をそのまま色相値として使えます。
3 番目はボタンのラベルではなくボタンの幅でした。06 の配色タブは display: grid の 1fr マスに並べてありますが、<button> は既定で fit-content の幅に縮む部品なので、width: 100% を明示しないとマスを埋めず、押されたタブの背景色が点く瞬間に揺れました。状態の交換は steps(1, end) で 1 フレームに切って解決ですが、カットだけだと動いたフレームが 2〜3 枚に落ちて測定の下限を超えられません — そこでチップが 40 ミリ秒ずつずれて跳ねる scale ポップを重ねてフレームを埋めました。同じ場所で、押されたタブの表示も一緒にカットしています。プレビューが三色配色を見せているのに押されたタブがまだ補色を指していたら、その画面はコードが間違っているのではなく、画面が嘘をついていることになります。
05 の履歴も同じ落とし穴を別の形で踏みました。マスを実際に横へ押す作りにすると、8 つ目のマスが消える瞬間にトラックが一度跳ね、ループが元へ戻るときにテレポートが生まれます。そこでマスは 1 ピクセルも動かさず、色だけを steps(1, end) で入れ替え、マスごとに位相を 0.25 秒ずつずらしました。8 色が 0.25 秒ごとに 1 マス移ること自体が「先頭に割り込んで後ろが詰まる」ことであり、2 秒後にはちょうど最初の色へ戻ります。
最後は 9 種が共有する幾何です。カードの幅 288px に内側 264px — つまみの位置を「264px の 1% は 2.64px」でパーセントから計算するのは 04 と 08 の 2 種です。01 は彩度 0〜1 に 264px をそのまま掛け、09 はキャンバスの絶対ピクセル座標を読む、同じ 264px の別の計り方です。この 1 行がずれると、つまみが指す位置と値がずれます。同じ理由で 09 のルーペは位置と 3 つの色の値を同じパーセント・同じ linear のイージングで動かします — キャンバスのグラデーションはチャネルごとに 1 次補間なので、位置を線形に動かせば色も線形に付いてきて、どのフレームでもルーペの色がその位置のピクセルと同じになります。9 種のソースとこのページが測った値がそのまま zip に入っており、開くパスワードは zg928jh3 です。
アクセシビリティ
動きを減らしてほしいという設定(prefers-reduced-motion: reduce)では、9 種すべてが同じ原則に従います — 自分で回るプレビューのループは止めて、ピッカー自体は完成した姿で残ります。静的な規則を先に書いてその上に .is-demo のループを重ねる構造なので、アニメーションを消しても 01 のつまみは定位置にあり、07 は AA バッジが点いたまま(AAA はまだ点く前の「不足」の姿です)、09 のルーペは絵の上に立っています。reduce ブロックの宣言にすべて !important が付いているのは、ループ側がクラスを重ねて積む形なので、詳細度で負けないようにするためです。
キーボードは 6 種のスライダー(01・02・04・06・08・09)が role="slider" と aria-valuetext を持ちます — 値だけでなく「彩度 94%、明度 55%、#08468C」のように数字と色を同じ文で読み上げます。矢印キーで小刻みに、Shift を押すと大股に動きます。色だけで状態を伝えない原則は 05 と 07 が守ります — 選ばれたマスには枠のリングと HEX のラベルが合わせて付き、バッジは「合格」・「不足」の文字を合わせて付けます。07 のコントラスト比の出力は aria-live="polite" で、変わるたびに読み直されます。
ピッカー内の文字のコントラストは、07 と同じ式で計算した値です。
| 場所 | 前・後の色 | 実測比 | 基準 |
|---|---|---|---|
| 01 カード文字 | インク / クリーム | 17.11:1 | AA・AAA 合格 |
| 02 カード文字 | クリーム / インク | 17.11:1 | AA・AAA 合格 |
| 03 カード文字 | インク / クリーム | 17.11:1 | AA・AAA 合格 |
| 07 見本文字(白) | 白 / 青 #2F6DF6 | 4.53:1 | AA 合格 |
| 09 ステージ文字 | インク / オレンジ | 5.5:1 | AA 合格 |
| 08 コード文字(インク 74%) | インク / クリーム | 7.57:1 | AA・AAA 合格 |
07 の 4.53:1 はデモ自身がはじき出す値です — 07 が巡る 5 つの背景のうち #2F6DF6 の段が白い文字に対してちょうど 4.53 を出し、AA のしきい値(4.5)を超える瞬間をそのまま見せます。計算の根拠は MDN のコントラスト文書とW3C WCAG 2.x の解説に沿いました。
色を選ぶほかの軸はセグメントコントロール UI 9 選とカレンダー選択 UI 9 選に集まっており、このサイトがデモをどう描画し測ってから記事を書くかは紹介ページに書いてあります。スライダー全般の作り方をさらに広く知りたい場合は、同じ picker の柱にある時刻選択 UI 9 選も合わせて読むと、つまみの動かし方の幅が広がります。
FAQ
ライブラリなしで作るとブラウザ互換性は大丈夫ですか?
この記事が使った技術の対応範囲は広いです。2026-09-12 の caniuse 実測で、@property は全体の 95.01%(Chrome 85+・Edge 85+・Firefox 128+・Safari 16.4+)、counter-set は 95.27%、@counter-style は 95.29% です。狭い例外は 09 の EyeDropper API で、全体の 30.8%(Chrome・Edge 95+、Firefox・Safari は非対応)です — だからこそ対応を確認してから、ないときはキャンバスの上で拾うフォールバックを一緒に付けてあります。
<input type="color"> を使えばいいのでは?
簡単な場所ではそれが正解です。ただしネイティブの色入力はブラウザごとに窓が違い、表記の連動・アルファ・配色・コントラスト判定のような機能は最初からありません。この記事の部品は、それらが必要になったときに貼るものです — 01 の四角だけを借りても、表記がつまみと食い違わない構造がすでに入っています。
保存するリストの長さはどこで変えますか?
05 がその骨組みです。localStorage のキー名(godrich-recent-colors)と、slice(0, 8) の 8 がリストの長さです。サーバーに残したいときは save() の中の 1 行だけを差し替えます — 読み書きの形がすでに 1 か所に集まっているからです。