html カラーピッカー デザイン9選 — ホイールから履歴色まで
「html カラーピッカー デザイン」で検索すると、色を選ぶ見た目だけを真似た静止画像がほとんど出てきます。ここで扱う9つの画面はその手前に計算の層を1枚重ねます。
01 color-picker-ios-wheel
02 color-picker-material-slider
03 color-picker-windows-spectrum
04 color-picker-macos-crayon
05 color-picker-figma-panel
06 color-picker-hsl-sliders
07 color-picker-swatch-chips
08 color-picker-eyedropper
09 color-picker-recent-colors
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 iOSホイール — 選択地点の角度から色相を求め直す円形カラーピッカー
- 02 Materialスライダー — 3つのつまみの位置を合わせてプレビュー色を計算するカラーピッカー
- 03 Windowsスペクトラム — 2次元の位置を彩度・明度に分けて求めるカラーピッカー
- 04 macOSクレヨン — 明るさの値で枠線の色を決めるグリッド型カラーピッカー
- 05 Figmaパネル — 3つの表記を互いに合わせて求め直すカラーピッカー
- 06 HSLスライダー — 色相の値に応じてトラックのグラデーションを描き直すカラーピッカー
- 07 スウォッチチップ — 明るさの値でチェックアイコンの対比を合わせるカラーピッカー
- 08 スポイト — 抽出された色の明るさでプレビューを求め直すカラーピッカー
- 09 履歴色 — 色同士の距離を計算し近い色をまとめるリスト型カラーピッカー
デモ
9項目とも「見た目の演出ではなく、選択地点の角度・つまみの位置比率・グリッドの選択インデックス・キャンバス上のピクセル座標・色同士のユークリッド距離という値を毎フレーム求め直してハンドルの位置・トラックのグラデーション・枠線とチェックアイコンの色・統合後の色数に反映する」という同じ前提を共有しています。実測値は次のとおりです。
| 番号 | 項目 | 累積面積 | 強度平均 | 動いたフレーム |
|---|---|---|---|---|
| 01 | iOSホイール | 8.494% | 88.7 | 23/23 |
| 02 | Materialスライダー | 17.368% | 72.3 | 23/23 |
| 03 | Windowsスペクトラム | 15.937% | 38.1 | 23/23 |
| 04 | macOSクレヨン | 14.251% | 56.6 | 23/23 |
| 05 | Figmaパネル | 10.797% | 42.9 | 23/23 |
| 06 | HSLスライダー | 15.53% | 44.5 | 23/23 |
| 07 | スウォッチチップ | 9.408% | 116.1 | 7/23 |
| 08 | スポイト | 8.365% | 98.7 | 23/23 |
| 09 | 履歴色 | 13.808% | 45.6 | 23/23 |
02Materialスライダーが累積面積17.368%で最も広いのは、3つのつまみとバー・プレビューが同時に動くためです。08スポイトの8.365%が最も狭いのは、動くのがルーペとプレビューの枠だけで、背景のキャンバス自体は最初に一度描いた4本の帯のまま変わらないからです。強度平均は03Windowsスペクトラムが38.1で最も落ち着いており(正方形の中を滑らかに移動するため色の変化も緩やかです)、07スウォッチチップが116.1で最も高くなっています(SEG_MS400msごとに色が瞬時に切り替わるため画面差分が大きく出ます)。07が動いたフレーム7/23で最も少ないのも同じ理由で、6色の間を連続移動せず一定間隔で切り替える構造上の性質であり、欠陥ではありません(コンポーネントの精巧さ基準)。9項目とも下限(8%)は満たしており、最小の08でも8.365%とわずかに上回っています。実際のユーザー操作に反応するリスナーは0項目で、9項目全てrequestAnimationFrameによる固定周期の自動再生だけで示されます。
カラーピッカーUIの解剖 — 9つの画面が計算するもの
「カラーピッカー ui」で検索して出てくるもののほとんどは、色相環や四角形パッドの見た目だけを整えた静的な画像です。ここで扱う9つの画面は、その手前に計算の層を1枚重ねます。座標・時間経過・ピクセルの明るさ・色同士の距離というデータを毎回読み直し、ハンドルの位置・トラックのグラデーション・枠線の色・チェックアイコンの対比・統合後の色数に変換し直す設計です。
何が入力で何が出力か
入力は円形・正方形パッド上の座標、スライダーやグリッドの位置比率、キャンバス上のピクセル座標、色同士のユークリッド距離という4種類です。出力はCSS変数(--hueや--colsなど)か、DOMに直接書き込むスタイル・テキスト・aria属性のどちらかで、レイアウトのキーフレーム自体は変えません。
なぜ一枚の層に計算をまとめるのか
色空間の変換をあちこちに散らすと、LUM_THRESHOLDやMERGE_DISTのような定数を1つ変えるたびに複数箇所を直す必要が出ます。applyのような専用の関数に集めておけば、定数を差し替えるだけで表示全体のつじつまが揃います。自分のカラーピッカーを自作するときも、同じように計算をひとつの層にまとめておくと後から値を調整しやすくなります。
画面別コード
「カラーピッカー css」で探して来た方は、まずこの9項目の実装から見るのが早いです。同じ「スポイト」でも、カラーピッカー UI 9選の09は実際のEyeDropper APIで画面全体から色を拾いますが、この08はgetImageDataで自前のキャンバスだけを読み取ります。9項目とも実キー・実サーバーへの通信は一切なく、プラットフォームの名前(iOS・Material・Windows・macOS・Figma)はいずれも見た目の系統を指す説明的な参照で、各社の実際の仕様やバージョンを指すものではありません。
01iOSホイール — 選択地点の角度から色相を求め直す円形カラーピッカー
円形パッド上を巡る6地点の座標からMath.atan2で角度を求め直して色相にし、中心からの距離をRADIUS60に対する比率として彩度にも変換して、色相環に沿った円形カラーピッカーの見た目を毎フレーム描き直します。

<main class="stage">
<section class="panel">
<div class="wheelWrap">
<div class="wheel" id="wheel" role="slider" aria-label="カラーホイール" aria-valuemin="0" aria-valuemax="360" aria-valuenow="0" aria-valuetext="">
<div class="handle" id="handle"></div>
</div>
</div>
<div class="readout" id="readout">
<span class="readoutLabel">選択した色</span>
<span class="readoutHex" id="hex">#000000</span>
</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.panel {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-4;
}
.wheelWrap {
position: relative;
width: 132px;
height: 132px;
}
.wheel {
position: relative;
width: 100%;
height: 100%;
border-radius: $r-pill;
box-shadow: $shadow-raised;
background:
radial-gradient(circle at center, hsl(0, 0%, 100%) 0%, hsla(0, 0%, 100%, 0) 62%),
conic-gradient(
hsl(0, 100%, 50%), hsl(60, 100%, 50%), hsl(120, 100%, 50%),
hsl(180, 100%, 50%), hsl(240, 100%, 50%), hsl(300, 100%, 50%), hsl(360, 100%, 50%)
);
}
.handle {
position: absolute;
top: 50%;
left: 50%;
width: 18px;
height: 18px;
border-radius: $r-pill;
border: 3px solid $subject-cream;
box-shadow: $shadow-press;
transition: none;
}
.readout {
display: flex;
align-items: center;
gap: $sp-2;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-blue-deep;
color: $subject-cream;
&.is-ink {
color: $subject-ink;
}
}
.readoutLabel {
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 500;
font-size: 11px;
line-height: 1.3;
opacity: .84;
}
.readoutHex {
font-family: "Space Mono", ui-monospace, monospace;
font-weight: 700;
font-size: 13px;
line-height: 1.3;
}
@media (max-width: 340px) {
.wheelWrap { width: 100px; height: 100px; }
.handle { width: 14px; height: 14px; }
.readoutLabel { font-size: 10px; }
.readoutHex { font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.handle { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.panel {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.wheelWrap {
position: relative;
width: 132px;
height: 132px;
}
.wheel {
position: relative;
width: 100%;
height: 100%;
border-radius: 999px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: radial-gradient(circle at center, hsl(0, 0%, 100%) 0%, hsla(0, 0%, 100%, 0) 62%), conic-gradient(hsl(0, 100%, 50%), hsl(60, 100%, 50%), hsl(120, 100%, 50%), hsl(180, 100%, 50%), hsl(240, 100%, 50%), hsl(300, 100%, 50%), hsl(0, 100%, 50%));
}
.handle {
position: absolute;
top: 50%;
left: 50%;
width: 18px;
height: 18px;
border-radius: 999px;
border: 3px solid #fff7e6;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: none;
}
.readout {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 12px;
border-radius: 999px;
background: #0a4fe8;
color: #fff7e6;
}
.readout.is-ink {
color: #17141a;
}
.readoutLabel {
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 500;
font-size: 11px;
line-height: 1.3;
opacity: 0.84;
}
.readoutHex {
font-family: "Space Mono", ui-monospace, monospace;
font-weight: 700;
font-size: 13px;
line-height: 1.3;
}
@media (max-width: 340px) {
.wheelWrap {
width: 100px;
height: 100px;
}
.handle {
width: 14px;
height: 14px;
}
.readoutLabel {
font-size: 10px;
}
.readoutHex {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.handle {
transition: none;
}
}// iOS 휠(01) — 손잡이의 중심 기준 각도를 라디안으로 다시 구해 색조로,
// 중심에서의 거리를 반지름 비율로 다시 구해 채도로 바꾸는 원형 컬러피커.
// 실키·실서버 요청 없음 — 좌표 궤적은 고정 시나리오로 자동 재생한다.
(function () {
var wheelEl = document.getElementById('wheel');
var handleEl = document.getElementById('handle');
var hexEl = document.getElementById('hex');
var readoutEl = document.getElementById('readout');
if (!wheelEl || !handleEl || !hexEl || !readoutEl) return;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var RADIUS = 60;
var LIGHTNESS = 55;
var CYCLE_MS = 2000;
// 드래그 궤적 — 반지름이 계속 바뀌는 여섯 지점을 순서대로 지난다 (286 의 단순 회전과 다른 시연)
var WAYPOINTS = [
{ x: 52, y: 4 },
{ x: 24, y: 44 },
{ x: -30, y: 41 },
{ x: -56, y: -6 },
{ x: -9, y: -53 },
{ x: 36, y: -39 },
{ x: 52, y: 4 }
];
var SEG_MS = CYCLE_MS / (WAYPOINTS.length - 1);
function hslToRgb(h, s, l) {
s /= 100; l /= 100;
var k = function (n) { return (n + h / 30) % 12; };
var a = s * Math.min(l, 1 - l);
var f = function (n) { return l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1))); };
return [Math.round(f(0) * 255), Math.round(f(8) * 255), Math.round(f(4) * 255)];
}
function toHex(n) { return n.toString(16).padStart(2, '0'); }
function pointAt(t) {
var idx = Math.min(WAYPOINTS.length - 2, Math.floor(t / SEG_MS));
var frac = (t % SEG_MS) / SEG_MS;
var a = WAYPOINTS[idx];
var b = WAYPOINTS[idx + 1];
return { x: a.x + (b.x - a.x) * frac, y: a.y + (b.y - a.y) * frac };
}
function apply(t) {
var p = pointAt(t);
var angle = Math.atan2(p.y, p.x);
var hue = ((angle * 180 / Math.PI) + 360) % 360;
var dist = Math.min(1, Math.hypot(p.x, p.y) / RADIUS);
var sat = dist * 100;
var rgb = hslToRgb(hue, sat, LIGHTNESS);
var hex = '#' + toHex(rgb[0]) + toHex(rgb[1]) + toHex(rgb[2]);
var brightness = (rgb[0] * 299 + rgb[1] * 587 + rgb[2] * 114) / 1000;
var ink = brightness > 150;
handleEl.style.transform = 'translate(-50%, -50%) translate(' + p.x.toFixed(2) + 'px, ' + p.y.toFixed(2) + 'px)';
handleEl.style.background = hex;
readoutEl.style.background = hex;
readoutEl.classList.toggle('is-ink', ink);
hexEl.textContent = hex;
wheelEl.setAttribute('aria-valuenow', Math.round(hue));
wheelEl.setAttribute('aria-valuetext', hex);
}
if (reduce) {
apply(0);
return;
}
var start = null;
function loop(ts) {
if (start === null) start = ts;
var t = (ts - start) % CYCLE_MS;
apply(t);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();import { useEffect, useRef } from "react";
import styles from "./ColorPickerIosWheel.module.scss";
// iOS 휠 — 손잡이의 중심 기준 각도를 색조로, 중심 거리를 채도로 다시 구하는 원형 컬러피커.
// 실키·실서버 요청 없음. 좌표 궤적은 고정 시나리오로 자동 재생한다.
const RADIUS = 60;
const LIGHTNESS = 55;
const CYCLE_MS = 2000;
const WAYPOINTS = [
{ x: 52, y: 4 },
{ x: 24, y: 44 },
{ x: -30, y: 41 },
{ x: -56, y: -6 },
{ x: -9, y: -53 },
{ x: 36, y: -39 },
{ x: 52, y: 4 },
];
const SEG_MS = CYCLE_MS / (WAYPOINTS.length - 1);
function hslToRgb(h: number, s: number, l: number): [number, number, number] {
s /= 100;
l /= 100;
const k = (n: number) => (n + h / 30) % 12;
const a = s * Math.min(l, 1 - l);
const f = (n: number) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
return [Math.round(f(0) * 255), Math.round(f(8) * 255), Math.round(f(4) * 255)];
}
function toHex(n: number) {
return n.toString(16).padStart(2, "0");
}
function pointAt(t: number) {
const idx = Math.min(WAYPOINTS.length - 2, Math.floor(t / SEG_MS));
const frac = (t % SEG_MS) / SEG_MS;
const a = WAYPOINTS[idx];
const b = WAYPOINTS[idx + 1];
return { x: a.x + (b.x - a.x) * frac, y: a.y + (b.y - a.y) * frac };
}
export default function ColorPickerIosWheel() {
const wheelRef = useRef<HTMLDivElement>(null);
const handleRef = useRef<HTMLDivElement>(null);
const readoutRef = useRef<HTMLDivElement>(null);
const hexRef = useRef<HTMLSpanElement>(null);
useEffect(() => {
const wheel = wheelRef.current;
const handle = handleRef.current;
const readout = readoutRef.current;
const hexEl = hexRef.current;
if (!wheel || !handle || !readout || !hexEl) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
function apply(t: number) {
const p = pointAt(t);
const angle = Math.atan2(p.y, p.x);
const hue = ((angle * 180) / Math.PI + 360) % 360;
const dist = Math.min(1, Math.hypot(p.x, p.y) / RADIUS);
const sat = dist * 100;
const rgb = hslToRgb(hue, sat, LIGHTNESS);
const hex = "#" + toHex(rgb[0]) + toHex(rgb[1]) + toHex(rgb[2]);
const brightness = (rgb[0] * 299 + rgb[1] * 587 + rgb[2] * 114) / 1000;
const ink = brightness > 150;
handle!.style.transform = `translate(-50%, -50%) translate(${p.x.toFixed(2)}px, ${p.y.toFixed(2)}px)`;
handle!.style.background = hex;
readout!.style.background = hex;
readout!.classList.toggle(styles.isInk, ink);
hexEl!.textContent = hex;
wheel!.setAttribute("aria-valuenow", String(Math.round(hue)));
wheel!.setAttribute("aria-valuetext", hex);
}
if (reduce) {
apply(0);
return;
}
let start: number | null = null;
let raf = 0;
function loop(ts: number) {
if (start === null) start = ts;
const t = (ts - start) % CYCLE_MS;
apply(t);
raf = requestAnimationFrame(loop);
}
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}, []);
return (
<div className={styles.stage}>
<section className={styles.panel}>
<div className={styles.wheelWrap}>
<div
className={styles.wheel}
ref={wheelRef}
role="slider"
aria-label="색상환"
aria-valuemin={0}
aria-valuemax={360}
aria-valuenow={0}
aria-valuetext=""
>
<div className={styles.handle} ref={handleRef} />
</div>
</div>
<div className={styles.readout} ref={readoutRef}>
<span className={styles.readoutLabel}>선택한 색</span>
<span className={styles.readoutHex} ref={hexRef}>#000000</span>
</div>
</section>
</div>
);
}01 · iOSホイール — 選択地点の角度から色相を求め直す円形カラーピッカー — 計測 累積面積 8.494% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
Math.atan2とpointAtが角度から色相を求め直す
pointAtは6つのWAYPOINTSの間を線形補間して現在の座標を返し、applyがその座標の角度と距離から色相・彩度を求め直してハンドルの位置とHEXの表示を同時に書き換えます。
function apply(t) {
var p = pointAt(t);
var angle = Math.atan2(p.y, p.x);
var hue = ((angle * 180 / Math.PI) + 360) % 360;
var dist = Math.min(1, Math.hypot(p.x, p.y) / RADIUS);
var sat = dist * 100;
var rgb = hslToRgb(hue, sat, LIGHTNESS);
var hex = '#' + toHex(rgb[0]) + toHex(rgb[1]) + toHex(rgb[2]);
var brightness = (rgb[0] * 299 + rgb[1] * 587 + rgb[2] * 114) / 1000;
var ink = brightness > 150;
handleEl.style.transform = 'translate(-50%, -50%) translate(' + p.x.toFixed(2) + 'px, ' + p.y.toFixed(2) + 'px)';
handleEl.style.background = hex;
readoutEl.style.background = hex;
readoutEl.classList.toggle('is-ink', ink);
hexEl.textContent = hex;
wheelEl.setAttribute('aria-valuenow', Math.round(hue));
wheelEl.setAttribute('aria-valuetext', hex);
}
02Materialスライダー — 3つのつまみの位置を合わせてプレビュー色を計算するカラーピッカー
色相・彩度・明度3つのトラックの位置比率をvaluesAtで3区間に分けて求め直し、setThumbでそれぞれのつまみとバーの長さを同時に動かして、プレビュー色を毎フレーム合成し直すカラーピッカーです。

<main class="stage">
<section class="panel">
<div class="preview" id="preview">
<span class="previewLabel">プレビュー</span>
</div>
<div class="sliders">
<div class="row">
<span class="rowLabel">色相</span>
<div class="track">
<div class="fill fillHue" id="fillH"></div>
<div class="thumb" id="thumbH" role="slider" aria-label="色相" aria-valuemin="0" aria-valuemax="360" aria-valuenow="0"></div>
</div>
</div>
<div class="row">
<span class="rowLabel">彩度</span>
<div class="track">
<div class="fill fillSat" id="fillS"></div>
<div class="thumb" id="thumbS" role="slider" aria-label="彩度" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0"></div>
</div>
</div>
<div class="row">
<span class="rowLabel">明度</span>
<div class="track">
<div class="fill fillLight" id="fillL"></div>
<div class="thumb" id="thumbL" role="slider" aria-label="明度" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0"></div>
</div>
</div>
</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.panel {
display: flex;
flex-direction: column;
align-items: stretch;
gap: $sp-4;
width: 100%;
max-width: 220px;
}
.preview {
height: 56px;
border-radius: $r-card;
display: flex;
align-items: flex-end;
padding: $sp-2;
box-shadow: $shadow-raised;
color: $subject-cream;
&.is-ink {
color: $subject-ink;
}
}
.previewLabel {
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 500;
font-size: 11px;
line-height: 1.3;
}
.sliders {
display: flex;
flex-direction: column;
gap: $sp-2;
}
.row {
display: flex;
align-items: center;
gap: $sp-2;
}
.rowLabel {
flex: 0 0 auto;
width: 30px;
color: $subject-ink;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 500;
font-size: 11px;
line-height: 1.3;
}
.track {
position: relative;
flex: 1 1 auto;
height: 6px;
border-radius: $r-pill;
background: rgba($subject-ink, .1);
}
.fill {
position: absolute;
inset: 0;
border-radius: $r-pill;
transform-origin: left center;
transform: scaleX(0);
}
.fillHue { background: $subject-blue; }
.fillSat { background: $subject-blue; }
.fillLight { background: $subject-blue; }
.thumb {
position: absolute;
top: 50%;
left: 0;
width: 16px;
height: 16px;
border-radius: $r-pill;
background: $stage-paper;
border: 2px solid $subject-blue;
box-shadow: $shadow-press;
}
@media (max-width: 340px) {
.panel { max-width: 168px; }
.preview { height: 44px; }
.rowLabel { width: 24px; font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.thumb { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.panel {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 16px;
width: 100%;
max-width: 220px;
}
.preview {
height: 56px;
border-radius: 16px;
display: flex;
align-items: flex-end;
padding: 8px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
color: #fff7e6;
}
.preview.is-ink {
color: #17141a;
}
.previewLabel {
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 500;
font-size: 11px;
line-height: 1.3;
}
.sliders {
display: flex;
flex-direction: column;
gap: 8px;
}
.row {
display: flex;
align-items: center;
gap: 8px;
}
.rowLabel {
flex: 0 0 auto;
width: 30px;
color: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 500;
font-size: 11px;
line-height: 1.3;
}
.track {
position: relative;
flex: 1 1 auto;
height: 6px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.1);
}
.fill {
position: absolute;
inset: 0;
border-radius: 999px;
transform-origin: left center;
transform: scaleX(0);
}
.fillHue {
background: #2f6df6;
}
.fillSat {
background: #2f6df6;
}
.fillLight {
background: #2f6df6;
}
.thumb {
position: absolute;
top: 50%;
left: 0;
width: 16px;
height: 16px;
border-radius: 999px;
background: #ffffff;
border: 2px solid #2f6df6;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
@media (max-width: 340px) {
.panel {
max-width: 168px;
}
.preview {
height: 44px;
}
.rowLabel {
width: 24px;
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.thumb {
transition: none;
}
}// Material 슬라이더(02) — 色相·彩度·明度 세 손잡이의 트랙 위치 비율을 각각 다시 구하고,
// 세 값을 합쳐 プレビュー 색을 매 프레임 다시 계산한다. 실키·실서버 요청 없음.
(function () {
var previewEl = document.getElementById('preview');
var thumbH = document.getElementById('thumbH');
var thumbS = document.getElementById('thumbS');
var thumbL = document.getElementById('thumbL');
var fillH = document.getElementById('fillH');
var fillS = document.getElementById('fillS');
var fillL = document.getElementById('fillL');
if (!previewEl || !thumbH || !thumbS || !thumbL || !fillH || !fillS || !fillL) return;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var CYCLE_MS = 2000;
var TRACK_W = 156;
function hslToRgb(h, s, l) {
s /= 100; l /= 100;
var k = function (n) { return (n + h / 30) % 12; };
var a = s * Math.min(l, 1 - l);
var f = function (n) { return l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1))); };
return [Math.round(f(0) * 255), Math.round(f(8) * 255), Math.round(f(4) * 255)];
}
function valuesAt(t) {
var p = t / CYCLE_MS;
var h, s, l;
if (p < 1 / 3) {
var local = p / (1 / 3);
h = local * 360; s = 60; l = 50;
} else if (p < 2 / 3) {
var local2 = (p - 1 / 3) / (1 / 3);
h = 360; s = 20 + local2 * 70; l = 50;
} else {
var local3 = (p - 2 / 3) / (1 / 3);
h = 360; s = 90; l = 20 + local3 * 60;
}
return { h: h % 360, s: s, l: l };
}
function setThumb(thumbEl, fillEl, ratio) {
var x = ratio * TRACK_W;
thumbEl.style.transform = 'translate(calc(-50% + ' + x.toFixed(2) + 'px), -50%)';
fillEl.style.transform = 'scaleX(' + ratio.toFixed(4) + ')';
}
function apply(t) {
var v = valuesAt(t);
setThumb(thumbH, fillH, v.h / 360);
setThumb(thumbS, fillS, v.s / 100);
setThumb(thumbL, fillL, v.l / 100);
var rgb = hslToRgb(v.h, v.s, v.l);
var brightness = (rgb[0] * 299 + rgb[1] * 587 + rgb[2] * 114) / 1000;
previewEl.style.background = 'hsl(' + v.h.toFixed(1) + 'deg ' + v.s.toFixed(1) + '% ' + v.l.toFixed(1) + '%)';
previewEl.classList.toggle('is-ink', brightness > 150);
thumbH.setAttribute('aria-valuenow', Math.round(v.h));
thumbS.setAttribute('aria-valuenow', Math.round(v.s));
thumbL.setAttribute('aria-valuenow', Math.round(v.l));
}
if (reduce) {
apply(0);
return;
}
var start = null;
function loop(ts) {
if (start === null) start = ts;
var t = (ts - start) % CYCLE_MS;
apply(t);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();import { useEffect, useRef } from "react";
import styles from "./ColorPickerMaterialSlider.module.scss";
// Material 슬라이더 — 색조·채도·명도 세 손잡이의 트랙 위치 비율을 각각 다시 구하고
// 세 값을 합쳐 미리보기 색을 매 프레임 다시 계산한다. 실키·실서버 요청 없음.
const CYCLE_MS = 2000;
const TRACK_W = 156;
function hslToRgb(h: number, s: number, l: number): [number, number, number] {
s /= 100;
l /= 100;
const k = (n: number) => (n + h / 30) % 12;
const a = s * Math.min(l, 1 - l);
const f = (n: number) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
return [Math.round(f(0) * 255), Math.round(f(8) * 255), Math.round(f(4) * 255)];
}
function valuesAt(t: number) {
const p = t / CYCLE_MS;
let h: number, s: number, l: number;
if (p < 1 / 3) {
const local = p / (1 / 3);
h = local * 360; s = 60; l = 50;
} else if (p < 2 / 3) {
const local2 = (p - 1 / 3) / (1 / 3);
h = 360; s = 20 + local2 * 70; l = 50;
} else {
const local3 = (p - 2 / 3) / (1 / 3);
h = 360; s = 90; l = 20 + local3 * 60;
}
return { h: h % 360, s, l };
}
export default function ColorPickerMaterialSlider() {
const previewRef = useRef<HTMLDivElement>(null);
const thumbHRef = useRef<HTMLDivElement>(null);
const thumbSRef = useRef<HTMLDivElement>(null);
const thumbLRef = useRef<HTMLDivElement>(null);
const fillHRef = useRef<HTMLDivElement>(null);
const fillSRef = useRef<HTMLDivElement>(null);
const fillLRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const preview = previewRef.current;
const thumbH = thumbHRef.current;
const thumbS = thumbSRef.current;
const thumbL = thumbLRef.current;
const fillH = fillHRef.current;
const fillS = fillSRef.current;
const fillL = fillLRef.current;
if (!preview || !thumbH || !thumbS || !thumbL || !fillH || !fillS || !fillL) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
function setThumb(thumbEl: HTMLDivElement, fillEl: HTMLDivElement, ratio: number) {
const x = ratio * TRACK_W;
thumbEl.style.transform = `translate(calc(-50% + ${x.toFixed(2)}px), -50%)`;
fillEl.style.transform = `scaleX(${ratio.toFixed(4)})`;
}
function apply(t: number) {
const v = valuesAt(t);
setThumb(thumbH!, fillH!, v.h / 360);
setThumb(thumbS!, fillS!, v.s / 100);
setThumb(thumbL!, fillL!, v.l / 100);
const rgb = hslToRgb(v.h, v.s, v.l);
const brightness = (rgb[0] * 299 + rgb[1] * 587 + rgb[2] * 114) / 1000;
preview!.style.background = `hsl(${v.h.toFixed(1)}deg ${v.s.toFixed(1)}% ${v.l.toFixed(1)}%)`;
preview!.classList.toggle(styles.isInk, brightness > 150);
thumbH!.setAttribute("aria-valuenow", String(Math.round(v.h)));
thumbS!.setAttribute("aria-valuenow", String(Math.round(v.s)));
thumbL!.setAttribute("aria-valuenow", String(Math.round(v.l)));
}
if (reduce) {
apply(0);
return;
}
let start: number | null = null;
let raf = 0;
function loop(ts: number) {
if (start === null) start = ts;
const t = (ts - start) % CYCLE_MS;
apply(t);
raf = requestAnimationFrame(loop);
}
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}, []);
return (
<div className={styles.stage}>
<section className={styles.panel}>
<div className={styles.preview} ref={previewRef}>
<span className={styles.previewLabel}>미리보기</span>
</div>
<div className={styles.sliders}>
<div className={styles.row}>
<span className={styles.rowLabel}>색상</span>
<div className={styles.track}>
<div className={`${styles.fill} ${styles.fillHue}`} ref={fillHRef} />
<div className={styles.thumb} ref={thumbHRef} role="slider" aria-label="색상" aria-valuemin={0} aria-valuemax={360} aria-valuenow={0} />
</div>
</div>
<div className={styles.row}>
<span className={styles.rowLabel}>채도</span>
<div className={styles.track}>
<div className={`${styles.fill} ${styles.fillSat}`} ref={fillSRef} />
<div className={styles.thumb} ref={thumbSRef} role="slider" aria-label="채도" aria-valuemin={0} aria-valuemax={100} aria-valuenow={0} />
</div>
</div>
<div className={styles.row}>
<span className={styles.rowLabel}>명도</span>
<div className={styles.track}>
<div className={`${styles.fill} ${styles.fillLight}`} ref={fillLRef} />
<div className={styles.thumb} ref={thumbLRef} role="slider" aria-label="명도" aria-valuemin={0} aria-valuemax={100} aria-valuenow={0} />
</div>
</div>
</div>
</section>
</div>
);
}02 · Materialスライダー — 3つのつまみの位置を合わせてプレビュー色を計算するカラーピッカー — 計測 累積面積 17.368% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
valuesAtとsetThumbが3つのつまみの位置を合わせる
applyはvaluesAtが返した色相・彩度・明度をそれぞれsetThumbに渡してつまみとバーを同時に動かし、そこから求めた輝度が150を超えるとプレビューの文字色を暗く切り替えます。
function apply(t) {
var v = valuesAt(t);
setThumb(thumbH, fillH, v.h / 360);
setThumb(thumbS, fillS, v.s / 100);
setThumb(thumbL, fillL, v.l / 100);
var rgb = hslToRgb(v.h, v.s, v.l);
var brightness = (rgb[0] * 299 + rgb[1] * 587 + rgb[2] * 114) / 1000;
previewEl.style.background = 'hsl(' + v.h.toFixed(1) + 'deg ' + v.s.toFixed(1) + '% ' + v.l.toFixed(1) + '%)';
previewEl.classList.toggle('is-ink', brightness > 150);
thumbH.setAttribute('aria-valuenow', Math.round(v.h));
thumbS.setAttribute('aria-valuenow', Math.round(v.s));
thumbL.setAttribute('aria-valuenow', Math.round(v.l));
}
03Windowsスペクトラム — 2次元の位置を彩度・明度に分けて求めるカラーピッカー
正方形パッド上を巡る6地点の座標を彩度・明度に、円周に沿ったサイクル位置を色相に分けて求め直し、hsvToRgbで色を合成し直すカラーピッカーです。

<main class="stage">
<section class="panel">
<div class="board">
<div class="square" id="square" style="--hue:0" role="slider" aria-label="彩度・明度エリア" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" aria-valuetext="">
<div class="squareMarker" id="squareMarker"></div>
</div>
<div class="hueStrip" id="hueStrip" role="slider" aria-label="色相" aria-valuemin="0" aria-valuemax="360" aria-valuenow="0">
<div class="hueMarker" id="hueMarker"></div>
</div>
</div>
<div class="readout" id="readout">
<span class="swatch" id="swatch"></span>
<span class="hex" id="hex">#000000</span>
</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-yellow);
.panel {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-4;
}
.board {
display: flex;
gap: $sp-3;
}
.square {
position: relative;
width: 140px;
height: 140px;
border-radius: $r-sm;
box-shadow: $shadow-raised;
background:
linear-gradient(to top, #000000 0%, rgba(0, 0, 0, 0) 100%),
linear-gradient(to right, #ffffff 0%, hsl(var(--hue) 100% 50%) 100%);
}
.squareMarker {
position: absolute;
top: 0;
left: 0;
width: 14px;
height: 14px;
border-radius: $r-pill;
border: 2px solid $subject-cream;
box-shadow: $shadow-press;
}
.hueStrip {
position: relative;
width: 18px;
height: 140px;
border-radius: $r-pill;
box-shadow: $shadow-raised;
background: linear-gradient(
to bottom,
hsl(0, 100%, 50%), hsl(60, 100%, 50%), hsl(120, 100%, 50%),
hsl(180, 100%, 50%), hsl(240, 100%, 50%), hsl(300, 100%, 50%), hsl(360, 100%, 50%)
);
}
.hueMarker {
position: absolute;
top: 0;
left: 50%;
width: 22px;
height: 8px;
border-radius: $r-xs;
border: 2px solid $subject-cream;
box-shadow: $shadow-press;
}
.readout {
display: flex;
align-items: center;
gap: $sp-2;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
color: $subject-cream;
&.is-ink {
color: $subject-ink;
}
}
.swatch {
width: 16px;
height: 16px;
border-radius: $r-pill;
box-shadow: $shadow-press;
}
.hex {
font-family: "Space Mono", ui-monospace, monospace;
font-weight: 700;
font-size: 13px;
line-height: 1.3;
}
@media (max-width: 340px) {
.square { width: 108px; height: 108px; }
.hueStrip { height: 108px; }
}
@media (prefers-reduced-motion: reduce) {
.squareMarker,
.hueMarker { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.panel {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.board {
display: flex;
gap: 12px;
}
.square {
position: relative;
width: 140px;
height: 140px;
border-radius: 8px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: linear-gradient(to top, #000000 0%, rgba(0, 0, 0, 0) 100%), linear-gradient(to right, #ffffff 0%, hsl(var(--hue), 100%, 50%) 100%);
}
.squareMarker {
position: absolute;
top: 0;
left: 0;
width: 14px;
height: 14px;
border-radius: 999px;
border: 2px solid #fff7e6;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.hueStrip {
position: relative;
width: 18px;
height: 140px;
border-radius: 999px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: linear-gradient(to bottom, hsl(0, 100%, 50%), hsl(60, 100%, 50%), hsl(120, 100%, 50%), hsl(180, 100%, 50%), hsl(240, 100%, 50%), hsl(300, 100%, 50%), hsl(0, 100%, 50%));
}
.hueMarker {
position: absolute;
top: 0;
left: 50%;
width: 22px;
height: 8px;
border-radius: 4px;
border: 2px solid #fff7e6;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.readout {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 12px;
border-radius: 999px;
color: #fff7e6;
}
.readout.is-ink {
color: #17141a;
}
.swatch {
width: 16px;
height: 16px;
border-radius: 999px;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.hex {
font-family: "Space Mono", ui-monospace, monospace;
font-weight: 700;
font-size: 13px;
line-height: 1.3;
}
@media (max-width: 340px) {
.square {
width: 108px;
height: 108px;
}
.hueStrip {
height: 108px;
}
}
@media (prefers-reduced-motion: reduce) {
.squareMarker,
.hueMarker {
transition: none;
}
}// Windows 스펙트럼(03) — 사각 영역 안 지점의 가로·세로 비율을 彩度·명도로, 옆 막대 값을 색조로
// 각각 다시 구해 매 프레임 최종 색을 합성한다. 실키·실서버 요청 없음.
(function () {
var squareEl = document.getElementById('square');
var squareMarkerEl = document.getElementById('squareMarker');
var hueStripEl = document.getElementById('hueStrip');
var hueMarkerEl = document.getElementById('hueMarker');
var readoutEl = document.getElementById('readout');
var swatchEl = document.getElementById('swatch');
var hexEl = document.getElementById('hex');
if (!squareEl || !squareMarkerEl || !hueStripEl || !hueMarkerEl || !readoutEl || !swatchEl || !hexEl) return;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var CYCLE_MS = 2400;
var SQUARE_W = 140;
var SQUARE_H = 140;
var STRIP_H = 140;
var WAYPOINTS = [
{ x: .15, y: .85 },
{ x: .85, y: .15 },
{ x: .5, y: .5 },
{ x: .15, y: .15 },
{ x: .85, y: .85 },
{ x: .15, y: .85 }
];
var SEG_MS = CYCLE_MS / (WAYPOINTS.length - 1);
function pointAt(t) {
var idx = Math.min(WAYPOINTS.length - 2, Math.floor(t / SEG_MS));
var frac = (t % SEG_MS) / SEG_MS;
var a = WAYPOINTS[idx];
var b = WAYPOINTS[idx + 1];
return { x: a.x + (b.x - a.x) * frac, y: a.y + (b.y - a.y) * frac };
}
function hsvToRgb(h, s, v) {
s /= 100; v /= 100;
var c = v * s;
var x = c * (1 - Math.abs(((h / 60) % 2) - 1));
var m = v - c;
var r, g, b;
if (h < 60) { r = c; g = x; b = 0; }
else if (h < 120) { r = x; g = c; b = 0; }
else if (h < 180) { r = 0; g = c; b = x; }
else if (h < 240) { r = 0; g = x; b = c; }
else if (h < 300) { r = x; g = 0; b = c; }
else { r = c; g = 0; b = x; }
return [Math.round((r + m) * 255), Math.round((g + m) * 255), Math.round((b + m) * 255)];
}
function toHex(n) {
return n.toString(16).padStart(2, '0');
}
function apply(t) {
var p = (t / CYCLE_MS) % 1;
var pt = pointAt(t);
var hue = p * 360;
var sat = pt.x * 100;
var val = (1 - pt.y) * 100;
var rgb = hsvToRgb(hue, sat, val);
var hex = '#' + toHex(rgb[0]) + toHex(rgb[1]) + toHex(rgb[2]);
var brightness = (rgb[0] * 299 + rgb[1] * 587 + rgb[2] * 114) / 1000;
var ink = brightness > 150;
squareEl.style.setProperty('--hue', hue.toFixed(1));
squareMarkerEl.style.transform = 'translate(-50%, -50%) translate(' + (pt.x * SQUARE_W).toFixed(2) + 'px, ' + (pt.y * SQUARE_H).toFixed(2) + 'px)';
hueMarkerEl.style.transform = 'translate(-50%, -50%) translateY(' + ((hue / 360) * STRIP_H).toFixed(2) + 'px)';
swatchEl.style.background = hex;
readoutEl.style.background = hex;
readoutEl.classList.toggle('is-ink', ink);
hexEl.textContent = hex;
squareEl.setAttribute('aria-valuenow', String(Math.round(val)));
squareEl.setAttribute('aria-valuetext', hex);
hueStripEl.setAttribute('aria-valuenow', String(Math.round(hue)));
}
if (reduce) {
apply(0);
return;
}
var start = null;
function loop(ts) {
if (start === null) start = ts;
var t = (ts - start) % CYCLE_MS;
apply(t);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();import { useEffect, useRef } from "react";
import styles from "./ColorPickerWindowsSpectrum.module.scss";
// Windows 스펙트럼 — 사각 영역 안 지점의 가로·세로 비율을 채도·명도로, 옆 막대 값을 색조로
// 각각 다시 구해 매 프레임 최종 색을 합성한다. 실키·실서버 요청 없음.
const CYCLE_MS = 2400;
const SQUARE_W = 140;
const SQUARE_H = 140;
const STRIP_H = 140;
const WAYPOINTS = [
{ x: .15, y: .85 },
{ x: .85, y: .15 },
{ x: .5, y: .5 },
{ x: .15, y: .15 },
{ x: .85, y: .85 },
{ x: .15, y: .85 },
];
const SEG_MS = CYCLE_MS / (WAYPOINTS.length - 1);
function pointAt(t: number) {
const idx = Math.min(WAYPOINTS.length - 2, Math.floor(t / SEG_MS));
const frac = (t % SEG_MS) / SEG_MS;
const a = WAYPOINTS[idx];
const b = WAYPOINTS[idx + 1];
return { x: a.x + (b.x - a.x) * frac, y: a.y + (b.y - a.y) * frac };
}
function hsvToRgb(h: number, s: number, v: number): [number, number, number] {
s /= 100;
v /= 100;
const c = v * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m = v - c;
let r: number, g: number, b: number;
if (h < 60) { r = c; g = x; b = 0; }
else if (h < 120) { r = x; g = c; b = 0; }
else if (h < 180) { r = 0; g = c; b = x; }
else if (h < 240) { r = 0; g = x; b = c; }
else if (h < 300) { r = x; g = 0; b = c; }
else { r = c; g = 0; b = x; }
return [Math.round((r + m) * 255), Math.round((g + m) * 255), Math.round((b + m) * 255)];
}
function toHex(n: number) {
return n.toString(16).padStart(2, "0");
}
export default function ColorPickerWindowsSpectrum() {
const squareRef = useRef<HTMLDivElement>(null);
const squareMarkerRef = useRef<HTMLDivElement>(null);
const hueStripRef = useRef<HTMLDivElement>(null);
const hueMarkerRef = useRef<HTMLDivElement>(null);
const readoutRef = useRef<HTMLDivElement>(null);
const swatchRef = useRef<HTMLSpanElement>(null);
const hexRef = useRef<HTMLSpanElement>(null);
useEffect(() => {
const square = squareRef.current;
const squareMarker = squareMarkerRef.current;
const hueStrip = hueStripRef.current;
const hueMarker = hueMarkerRef.current;
const readout = readoutRef.current;
const swatch = swatchRef.current;
const hexEl = hexRef.current;
if (!square || !squareMarker || !hueStrip || !hueMarker || !readout || !swatch || !hexEl) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
function apply(t: number) {
const p = (t / CYCLE_MS) % 1;
const pt = pointAt(t);
const hue = p * 360;
const sat = pt.x * 100;
const val = (1 - pt.y) * 100;
const rgb = hsvToRgb(hue, sat, val);
const hex = "#" + toHex(rgb[0]) + toHex(rgb[1]) + toHex(rgb[2]);
const brightness = (rgb[0] * 299 + rgb[1] * 587 + rgb[2] * 114) / 1000;
const ink = brightness > 150;
square!.style.setProperty("--hue", hue.toFixed(1));
squareMarker!.style.transform = `translate(-50%, -50%) translate(${(pt.x * SQUARE_W).toFixed(2)}px, ${(pt.y * SQUARE_H).toFixed(2)}px)`;
hueMarker!.style.transform = `translate(-50%, -50%) translateY(${((hue / 360) * STRIP_H).toFixed(2)}px)`;
swatch!.style.background = hex;
readout!.style.background = hex;
readout!.classList.toggle(styles.isInk, ink);
hexEl!.textContent = hex;
square!.setAttribute("aria-valuenow", String(Math.round(val)));
square!.setAttribute("aria-valuetext", hex);
hueStrip!.setAttribute("aria-valuenow", String(Math.round(hue)));
}
if (reduce) {
apply(0);
return;
}
let start: number | null = null;
let raf = 0;
function loop(ts: number) {
if (start === null) start = ts;
const t = (ts - start) % CYCLE_MS;
apply(t);
raf = requestAnimationFrame(loop);
}
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}, []);
return (
<div className={styles.stage}>
<section className={styles.panel}>
<div className={styles.board}>
<div
className={styles.square}
ref={squareRef}
role="slider"
aria-label="채도·명도 영역"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={0}
aria-valuetext=""
>
<div className={styles.squareMarker} ref={squareMarkerRef} />
</div>
<div className={styles.hueStrip} ref={hueStripRef} role="slider" aria-label="색조" aria-valuemin={0} aria-valuemax={360} aria-valuenow={0}>
<div className={styles.hueMarker} ref={hueMarkerRef} />
</div>
</div>
<div className={styles.readout} ref={readoutRef}>
<span className={styles.swatch} ref={swatchRef} />
<span className={styles.hex} ref={hexRef}>#000000</span>
</div>
</section>
</div>
);
}03 · Windowsスペクトラム — 2次元の位置を彩度・明度に分けて求めるカラーピッカー — 計測 累積面積 15.937% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
hsvToRgbとpointAtが2次元の位置を彩度・明度に分ける
pointAtが返す0から1の座標をそのまま彩度と明度の比率として使い、hsvToRgbがその2値と色相を合わせてHEXを求め直してマーカーの位置とスウォッチの背景を同時に書き換えます。
function apply(t) {
var p = (t / CYCLE_MS) % 1;
var pt = pointAt(t);
var hue = p * 360;
var sat = pt.x * 100;
var val = (1 - pt.y) * 100;
var rgb = hsvToRgb(hue, sat, val);
var hex = '#' + toHex(rgb[0]) + toHex(rgb[1]) + toHex(rgb[2]);
var brightness = (rgb[0] * 299 + rgb[1] * 587 + rgb[2] * 114) / 1000;
var ink = brightness > 150;
squareEl.style.setProperty('--hue', hue.toFixed(1));
squareMarkerEl.style.transform = 'translate(-50%, -50%) translate(' + (pt.x * SQUARE_W).toFixed(2) + 'px, ' + (pt.y * SQUARE_H).toFixed(2) + 'px)';
hueMarkerEl.style.transform = 'translate(-50%, -50%) translateY(' + ((hue / 360) * STRIP_H).toFixed(2) + 'px)';
swatchEl.style.background = hex;
readoutEl.style.background = hex;
readoutEl.classList.toggle('is-ink', ink);
hexEl.textContent = hex;
squareEl.setAttribute('aria-valuenow', String(Math.round(val)));
squareEl.setAttribute('aria-valuetext', hex);
hueStripEl.setAttribute('aria-valuenow', String(Math.round(hue)));
}
04macOSクレヨン — 明るさの値で枠線の色を決めるグリッド型カラーピッカー
9色のCRAYONSグリッドの中心座標をcenterOfで求め直し、選択中の色の明るさがLUM_THRESHOLD .6を下回ると白い枠線に、上回ると黒い枠線に選択リングの色を切り替えるカラーピッカーです。

<main class="stage">
<section class="panel">
<div class="grid" id="grid" role="radiogroup" aria-label="クレヨンパレット">
<span class="crayon" data-i="0" role="radio" aria-checked="false" aria-label="クレヨン1"></span>
<span class="crayon" data-i="1" role="radio" aria-checked="false" aria-label="クレヨン2"></span>
<span class="crayon" data-i="2" role="radio" aria-checked="false" aria-label="クレヨン3"></span>
<span class="crayon" data-i="3" role="radio" aria-checked="false" aria-label="クレヨン4"></span>
<span class="crayon" data-i="4" role="radio" aria-checked="false" aria-label="クレヨン5"></span>
<span class="crayon" data-i="5" role="radio" aria-checked="false" aria-label="クレヨン6"></span>
<span class="crayon" data-i="6" role="radio" aria-checked="false" aria-label="クレヨン7"></span>
<span class="crayon" data-i="7" role="radio" aria-checked="false" aria-label="クレヨン8"></span>
<span class="crayon" data-i="8" role="radio" aria-checked="false" aria-label="クレヨン9"></span>
<div class="ring" id="ring"></div>
</div>
<div class="readout" id="readout">
<span class="swatch" id="swatch"></span>
<span class="hex" id="hex">#000000</span>
</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.panel {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-4;
}
.grid {
position: relative;
display: grid;
grid-template-columns: repeat(3, 40px);
grid-template-rows: repeat(3, 40px);
gap: $sp-3;
}
.crayon {
width: 40px;
height: 40px;
border-radius: $r-pill;
box-shadow: $shadow-press;
}
.ring {
position: absolute;
top: 0;
left: 0;
width: 40px;
height: 40px;
border-radius: $r-pill;
border: 3px solid $subject-cream;
box-shadow: $shadow-raised;
pointer-events: none;
&.is-black-ring {
border-color: $subject-ink;
}
}
.readout {
display: flex;
align-items: center;
gap: $sp-2;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-cream;
box-shadow: $shadow-press;
}
.swatch {
width: 16px;
height: 16px;
border-radius: $r-pill;
box-shadow: $shadow-press;
}
.hex {
color: $subject-ink;
font-family: "Space Mono", ui-monospace, monospace;
font-weight: 700;
font-size: 13px;
line-height: 1.3;
}
@media (max-width: 340px) {
.grid {
grid-template-columns: repeat(3, 32px);
grid-template-rows: repeat(3, 32px);
gap: $sp-2;
}
.crayon { width: 32px; height: 32px; }
.ring { width: 32px; height: 32px; }
}
@media (prefers-reduced-motion: reduce) {
.ring { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.panel {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.grid {
position: relative;
display: grid;
grid-template-columns: repeat(3, 40px);
grid-template-rows: repeat(3, 40px);
gap: 12px;
}
.crayon {
width: 40px;
height: 40px;
border-radius: 999px;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.ring {
position: absolute;
top: 0;
left: 0;
width: 40px;
height: 40px;
border-radius: 999px;
border: 3px solid #fff7e6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
pointer-events: none;
}
.ring.is-black-ring {
border-color: #17141a;
}
.readout {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 12px;
border-radius: 999px;
background: #fff7e6;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.swatch {
width: 16px;
height: 16px;
border-radius: 999px;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.hex {
color: #17141a;
font-family: "Space Mono", ui-monospace, monospace;
font-weight: 700;
font-size: 13px;
line-height: 1.3;
}
@media (max-width: 340px) {
.grid {
grid-template-columns: repeat(3, 32px);
grid-template-rows: repeat(3, 32px);
gap: 8px;
}
.crayon {
width: 32px;
height: 32px;
}
.ring {
width: 32px;
height: 32px;
}
}
@media (prefers-reduced-motion: reduce) {
.ring {
transition: none;
}
}// macOS 크레용(04) — 선택된 크레용 색의 밝기 값을 계산해 그 값이 낮으면 흰색 테두리,
// 높으면 검은색 테두리로 선택 표시가 자동으로 바뀐다. 실키·실서버 요청 없음.
(function () {
var gridEl = document.getElementById('grid');
var ringEl = document.getElementById('ring');
var readoutEl = document.getElementById('readout');
var swatchEl = document.getElementById('swatch');
var hexEl = document.getElementById('hex');
var crayonEls = gridEl ? gridEl.querySelectorAll('.crayon') : [];
if (!gridEl || !ringEl || !readoutEl || !swatchEl || !hexEl || crayonEls.length !== 9) return;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var LUM_THRESHOLD = .6;
var CELL = 40;
var GAP = 12;
var STEP = CELL + GAP;
var CYCLE_MS = 2700;
var CRAYONS = [
{ h: 0, s: 70, l: 50 },
{ h: 40, s: 80, l: 60 },
{ h: 80, s: 60, l: 45 },
{ h: 120, s: 50, l: 35 },
{ h: 160, s: 55, l: 70 },
{ h: 200, s: 65, l: 50 },
{ h: 240, s: 60, l: 35 },
{ h: 280, s: 50, l: 65 },
{ h: 320, s: 70, l: 55 }
];
var ORDER = [0, 1, 2, 3, 4, 5, 6, 7, 8, 0];
var SEG_MS = CYCLE_MS / (ORDER.length - 1);
function hslToRgb(h, s, l) {
s /= 100; l /= 100;
var k = function (n) { return (n + h / 30) % 12; };
var a = s * Math.min(l, 1 - l);
var f = function (n) { return l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1))); };
return [Math.round(f(0) * 255), Math.round(f(8) * 255), Math.round(f(4) * 255)];
}
function toHex(n) {
return n.toString(16).padStart(2, '0');
}
function centerOf(i) {
var col = i % 3;
var row = Math.floor(i / 3);
return { x: col * STEP, y: row * STEP };
}
CRAYONS.forEach(function (c, i) {
var rgb = hslToRgb(c.h, c.s, c.l);
crayonEls[i].style.background = 'hsl(' + c.h + 'deg ' + c.s + '% ' + c.l + '%)';
crayonEls[i].dataset.hex = '#' + toHex(rgb[0]) + toHex(rgb[1]) + toHex(rgb[2]);
});
function apply(t) {
var idx = Math.min(ORDER.length - 2, Math.floor(t / SEG_MS));
var frac = (t % SEG_MS) / SEG_MS;
var a = centerOf(ORDER[idx]);
var b = centerOf(ORDER[idx + 1]);
var x = a.x + (b.x - a.x) * frac;
var y = a.y + (b.y - a.y) * frac;
ringEl.style.transform = 'translate(' + x.toFixed(2) + 'px, ' + y.toFixed(2) + 'px)';
var selIndex = ORDER[frac < .5 ? idx : idx + 1];
var c = CRAYONS[selIndex];
var rgb = hslToRgb(c.h, c.s, c.l);
var brightness = (rgb[0] * 299 + rgb[1] * 587 + rgb[2] * 114) / 1000 / 255;
var white = brightness < LUM_THRESHOLD;
ringEl.classList.toggle('is-white-ring', white);
ringEl.classList.toggle('is-black-ring', !white);
var hex = crayonEls[selIndex].dataset.hex;
swatchEl.style.background = hex;
hexEl.textContent = hex;
for (var i = 0; i < crayonEls.length; i++) {
crayonEls[i].setAttribute('aria-checked', i === selIndex ? 'true' : 'false');
}
}
if (reduce) {
apply(0);
return;
}
var start = null;
function loop(ts) {
if (start === null) start = ts;
var t = (ts - start) % CYCLE_MS;
apply(t);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();import { useEffect, useRef } from "react";
import styles from "./ColorPickerMacosCrayon.module.scss";
// macOS 크레용 — 선택된 크레용 색의 밝기 값을 계산해 그 값이 낮으면 흰색 테두리,
// 높으면 검은색 테두리로 선택 표시가 자동으로 바뀐다. 실키·실서버 요청 없음.
const LUM_THRESHOLD = .6;
const CELL = 40;
const GAP = 12;
const STEP = CELL + GAP;
const CYCLE_MS = 2700;
const CRAYONS = [
{ h: 0, s: 70, l: 50 },
{ h: 40, s: 80, l: 60 },
{ h: 80, s: 60, l: 45 },
{ h: 120, s: 50, l: 35 },
{ h: 160, s: 55, l: 70 },
{ h: 200, s: 65, l: 50 },
{ h: 240, s: 60, l: 35 },
{ h: 280, s: 50, l: 65 },
{ h: 320, s: 70, l: 55 },
];
const ORDER = [0, 1, 2, 3, 4, 5, 6, 7, 8, 0];
const SEG_MS = CYCLE_MS / (ORDER.length - 1);
function hslToRgb(h: number, s: number, l: number): [number, number, number] {
s /= 100;
l /= 100;
const k = (n: number) => (n + h / 30) % 12;
const a = s * Math.min(l, 1 - l);
const f = (n: number) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
return [Math.round(f(0) * 255), Math.round(f(8) * 255), Math.round(f(4) * 255)];
}
function toHex(n: number) {
return n.toString(16).padStart(2, "0");
}
function centerOf(i: number) {
const col = i % 3;
const row = Math.floor(i / 3);
return { x: col * STEP, y: row * STEP };
}
const CRAYON_HEX = CRAYONS.map((c) => {
const rgb = hslToRgb(c.h, c.s, c.l);
return "#" + toHex(rgb[0]) + toHex(rgb[1]) + toHex(rgb[2]);
});
export default function ColorPickerMacosCrayon() {
const gridRef = useRef<HTMLDivElement>(null);
const ringRef = useRef<HTMLDivElement>(null);
const crayonRef0 = useRef<HTMLSpanElement>(null);
const crayonRef1 = useRef<HTMLSpanElement>(null);
const crayonRef2 = useRef<HTMLSpanElement>(null);
const crayonRef3 = useRef<HTMLSpanElement>(null);
const crayonRef4 = useRef<HTMLSpanElement>(null);
const crayonRef5 = useRef<HTMLSpanElement>(null);
const crayonRef6 = useRef<HTMLSpanElement>(null);
const crayonRef7 = useRef<HTMLSpanElement>(null);
const crayonRef8 = useRef<HTMLSpanElement>(null);
const swatchRef = useRef<HTMLSpanElement>(null);
const hexRef = useRef<HTMLSpanElement>(null);
useEffect(() => {
const grid = gridRef.current;
const ring = ringRef.current;
const swatch = swatchRef.current;
const hexEl = hexRef.current;
const crayons = [
crayonRef0.current, crayonRef1.current, crayonRef2.current,
crayonRef3.current, crayonRef4.current, crayonRef5.current,
crayonRef6.current, crayonRef7.current, crayonRef8.current,
];
if (!grid || !ring || !swatch || !hexEl || crayons.some((c) => !c)) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
function apply(t: number) {
const idx = Math.min(ORDER.length - 2, Math.floor(t / SEG_MS));
const frac = (t % SEG_MS) / SEG_MS;
const a = centerOf(ORDER[idx]);
const b = centerOf(ORDER[idx + 1]);
const x = a.x + (b.x - a.x) * frac;
const y = a.y + (b.y - a.y) * frac;
ring!.style.transform = `translate(${x.toFixed(2)}px, ${y.toFixed(2)}px)`;
const selIndex = ORDER[frac < .5 ? idx : idx + 1];
const c = CRAYONS[selIndex];
const rgb = hslToRgb(c.h, c.s, c.l);
const brightness = (rgb[0] * 299 + rgb[1] * 587 + rgb[2] * 114) / 1000 / 255;
const white = brightness < LUM_THRESHOLD;
ring!.classList.toggle(styles.isWhiteRing, white);
ring!.classList.toggle(styles.isBlackRing, !white);
const hex = CRAYON_HEX[selIndex];
swatch!.style.background = hex;
hexEl!.textContent = hex;
crayons.forEach((el, i) => el!.setAttribute("aria-checked", i === selIndex ? "true" : "false"));
}
if (reduce) {
apply(0);
return;
}
let start: number | null = null;
let raf = 0;
function loop(ts: number) {
if (start === null) start = ts;
const t = (ts - start) % CYCLE_MS;
apply(t);
raf = requestAnimationFrame(loop);
}
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}, []);
return (
<div className={styles.stage}>
<section className={styles.panel}>
<div className={styles.grid} ref={gridRef} role="radiogroup" aria-label="크레용 팔레트">
{[crayonRef0, crayonRef1, crayonRef2, crayonRef3, crayonRef4, crayonRef5, crayonRef6, crayonRef7, crayonRef8].map((r, i) => (
<span
key={i}
className={styles.crayon}
ref={r}
role="radio"
aria-checked="false"
aria-label={`크레용 ${i + 1}`}
style={{ background: `hsl(${CRAYONS[i].h}deg ${CRAYONS[i].s}% ${CRAYONS[i].l}%)` }}
/>
))}
<div className={styles.ring} ref={ringRef} />
</div>
<div className={styles.readout}>
<span className={styles.swatch} ref={swatchRef} />
<span className={styles.hex} ref={hexRef}>#000000</span>
</div>
</section>
</div>
);
}04 · macOSクレヨン — 明るさの値で枠線の色を決めるグリッド型カラーピッカー — 計測 累積面積 14.251% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
LUM_THRESHOLD .6がcenterOfで求めた明るさから枠線の色を決める
applyは選択中のクレヨンのRGB値から明るさを求め、その値がLUM_THRESHOLDを下回ればis-white-ringを、上回ればis-black-ringを選択リングに付け替えて、9マス全てのaria-checkedも同時に更新します。
function apply(t) {
var idx = Math.min(ORDER.length - 2, Math.floor(t / SEG_MS));
var frac = (t % SEG_MS) / SEG_MS;
var a = centerOf(ORDER[idx]);
var b = centerOf(ORDER[idx + 1]);
var x = a.x + (b.x - a.x) * frac;
var y = a.y + (b.y - a.y) * frac;
ringEl.style.transform = 'translate(' + x.toFixed(2) + 'px, ' + y.toFixed(2) + 'px)';
var selIndex = ORDER[frac < .5 ? idx : idx + 1];
var c = CRAYONS[selIndex];
var rgb = hslToRgb(c.h, c.s, c.l);
var brightness = (rgb[0] * 299 + rgb[1] * 587 + rgb[2] * 114) / 1000 / 255;
var white = brightness < LUM_THRESHOLD;
ringEl.classList.toggle('is-white-ring', white);
ringEl.classList.toggle('is-black-ring', !white);
var hex = crayonEls[selIndex].dataset.hex;
swatchEl.style.background = hex;
hexEl.textContent = hex;
for (var i = 0; i < crayonEls.length; i++) {
crayonEls[i].setAttribute('aria-checked', i === selIndex ? 'true' : 'false');
}
}
05Figmaパネル — 3つの表記を互いに合わせて求め直すカラーピッカー
PRESETS7色の間を巡回しながら同じ色をHEX・RGB・HSLの3つの表記へ毎フレーム求め直し、3つのフィールドが常に同じ色を指すよう合わせ直すカラーピッカーです。

<main class="stage">
<section class="panel">
<div class="swatch" id="swatch"></div>
<dl class="fields">
<div class="field">
<dt class="fieldLabel">HEX</dt>
<dd class="fieldValue" id="hexField">#000000</dd>
</div>
<div class="field">
<dt class="fieldLabel">RGB</dt>
<dd class="fieldValue" id="rgbField">rgb(0, 0, 0)</dd>
</div>
<div class="field">
<dt class="fieldLabel">HSL</dt>
<dd class="fieldValue" id="hslField">hsl(0deg 0% 0%)</dd>
</div>
</dl>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.panel {
display: flex;
flex-direction: column;
align-items: stretch;
gap: $sp-4;
width: 100%;
max-width: 220px;
}
.swatch {
height: 64px;
border-radius: $r-card;
box-shadow: $shadow-raised;
}
.fields {
display: flex;
flex-direction: column;
gap: $sp-2;
margin: 0;
}
.field {
display: flex;
align-items: center;
justify-content: space-between;
gap: $sp-2;
padding: $sp-2 $sp-3;
border-radius: $r-sm;
background: rgba($subject-cream, .08);
}
.fieldLabel {
margin: 0;
color: $subject-cream;
opacity: .82;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 600;
font-size: 10px;
line-height: 1.3;
letter-spacing: .04em;
}
.fieldValue {
margin: 0;
color: $subject-cream;
font-family: "Space Mono", ui-monospace, monospace;
font-weight: 700;
font-size: 12px;
line-height: 1.3;
}
@media (max-width: 340px) {
.panel { max-width: 168px; }
.swatch { height: 48px; }
.fieldValue { font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.swatch { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.panel {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 16px;
width: 100%;
max-width: 220px;
}
.swatch {
height: 64px;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.fields {
display: flex;
flex-direction: column;
gap: 8px;
margin: 0;
}
.field {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 12px;
border-radius: 8px;
background: rgba(255, 247, 230, 0.08);
}
.fieldLabel {
margin: 0;
color: #fff7e6;
opacity: 0.82;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 600;
font-size: 10px;
line-height: 1.3;
letter-spacing: 0.04em;
}
.fieldValue {
margin: 0;
color: #fff7e6;
font-family: "Space Mono", ui-monospace, monospace;
font-weight: 700;
font-size: 12px;
line-height: 1.3;
}
@media (max-width: 340px) {
.panel {
max-width: 168px;
}
.swatch {
height: 48px;
}
.fieldValue {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.swatch {
transition: none;
}
}// Figma 패널(05) — 하나의 색 값을 HEX·RGB·HSL 세 표기법으로 각각 다시 계산해,
// 세 필드가 항상 같은 색을 가리키도록 서로 맞춘다. 실키·실서버 요청 없음.
(function () {
var swatchEl = document.getElementById('swatch');
var hexField = document.getElementById('hexField');
var rgbField = document.getElementById('rgbField');
var hslField = document.getElementById('hslField');
if (!swatchEl || !hexField || !rgbField || !hslField) return;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var CYCLE_MS = 2600;
var PRESETS = [
{ h: 10, s: 72, l: 54 },
{ h: 70, s: 60, l: 48 },
{ h: 130, s: 55, l: 42 },
{ h: 190, s: 65, l: 50 },
{ h: 250, s: 60, l: 58 },
{ h: 310, s: 68, l: 52 },
{ h: 370, s: 72, l: 54 }
];
var SEG_MS = CYCLE_MS / (PRESETS.length - 1);
function hslToRgb(h, s, l) {
h = h % 360;
s /= 100; l /= 100;
var k = function (n) { return (n + h / 30) % 12; };
var a = s * Math.min(l, 1 - l);
var f = function (n) { return l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1))); };
return [Math.round(f(0) * 255), Math.round(f(8) * 255), Math.round(f(4) * 255)];
}
function toHex(n) {
return n.toString(16).padStart(2, '0');
}
function apply(t) {
var idx = Math.min(PRESETS.length - 2, Math.floor(t / SEG_MS));
var frac = (t % SEG_MS) / SEG_MS;
var a = PRESETS[idx];
var b = PRESETS[idx + 1];
var h = a.h + (b.h - a.h) * frac;
var s = a.s + (b.s - a.s) * frac;
var l = a.l + (b.l - a.l) * frac;
var hMod = h % 360;
var rgb = hslToRgb(h, s, l);
var hex = '#' + toHex(rgb[0]) + toHex(rgb[1]) + toHex(rgb[2]);
var hslStr = 'hsl(' + hMod.toFixed(0) + 'deg ' + s.toFixed(0) + '% ' + l.toFixed(0) + '%)';
var rgbStr = 'rgb(' + rgb[0] + ', ' + rgb[1] + ', ' + rgb[2] + ')';
swatchEl.style.background = hslStr;
hexField.textContent = hex;
rgbField.textContent = rgbStr;
hslField.textContent = hslStr;
}
if (reduce) {
apply(0);
return;
}
var start = null;
function loop(ts) {
if (start === null) start = ts;
var t = (ts - start) % CYCLE_MS;
apply(t);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();import { useEffect, useRef } from "react";
import styles from "./ColorPickerFigmaPanel.module.scss";
// Figma 패널 — 하나의 색 값을 HEX·RGB·HSL 세 표기법으로 각각 다시 계산해,
// 세 필드가 항상 같은 색을 가리키도록 서로 맞춘다. 실키·실서버 요청 없음.
const CYCLE_MS = 2600;
const PRESETS = [
{ h: 10, s: 72, l: 54 },
{ h: 70, s: 60, l: 48 },
{ h: 130, s: 55, l: 42 },
{ h: 190, s: 65, l: 50 },
{ h: 250, s: 60, l: 58 },
{ h: 310, s: 68, l: 52 },
{ h: 370, s: 72, l: 54 },
];
const SEG_MS = CYCLE_MS / (PRESETS.length - 1);
function hslToRgb(h: number, s: number, l: number): [number, number, number] {
h = h % 360;
s /= 100;
l /= 100;
const k = (n: number) => (n + h / 30) % 12;
const a = s * Math.min(l, 1 - l);
const f = (n: number) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
return [Math.round(f(0) * 255), Math.round(f(8) * 255), Math.round(f(4) * 255)];
}
function toHex(n: number) {
return n.toString(16).padStart(2, "0");
}
export default function ColorPickerFigmaPanel() {
const swatchRef = useRef<HTMLDivElement>(null);
const hexRef = useRef<HTMLElement>(null);
const rgbRef = useRef<HTMLElement>(null);
const hslRef = useRef<HTMLElement>(null);
useEffect(() => {
const swatch = swatchRef.current;
const hexField = hexRef.current;
const rgbField = rgbRef.current;
const hslField = hslRef.current;
if (!swatch || !hexField || !rgbField || !hslField) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
function apply(t: number) {
const idx = Math.min(PRESETS.length - 2, Math.floor(t / SEG_MS));
const frac = (t % SEG_MS) / SEG_MS;
const a = PRESETS[idx];
const b = PRESETS[idx + 1];
const h = a.h + (b.h - a.h) * frac;
const s = a.s + (b.s - a.s) * frac;
const l = a.l + (b.l - a.l) * frac;
const hMod = h % 360;
const rgb = hslToRgb(h, s, l);
const hex = "#" + toHex(rgb[0]) + toHex(rgb[1]) + toHex(rgb[2]);
const hslStr = `hsl(${hMod.toFixed(0)}deg ${s.toFixed(0)}% ${l.toFixed(0)}%)`;
const rgbStr = `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`;
swatch!.style.background = hslStr;
hexField!.textContent = hex;
rgbField!.textContent = rgbStr;
hslField!.textContent = hslStr;
}
if (reduce) {
apply(0);
return;
}
let start: number | null = null;
let raf = 0;
function loop(ts: number) {
if (start === null) start = ts;
const t = (ts - start) % CYCLE_MS;
apply(t);
raf = requestAnimationFrame(loop);
}
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}, []);
return (
<div className={styles.stage}>
<section className={styles.panel}>
<div className={styles.swatch} ref={swatchRef} />
<dl className={styles.fields}>
<div className={styles.field}>
<dt className={styles.fieldLabel}>HEX</dt>
<dd className={styles.fieldValue} ref={hexRef}>#000000</dd>
</div>
<div className={styles.field}>
<dt className={styles.fieldLabel}>RGB</dt>
<dd className={styles.fieldValue} ref={rgbRef}>rgb(0, 0, 0)</dd>
</div>
<div className={styles.field}>
<dt className={styles.fieldLabel}>HSL</dt>
<dd className={styles.fieldValue} ref={hslRef}>hsl(0deg 0% 0%)</dd>
</div>
</dl>
</section>
</div>
);
}05 · Figmaパネル — 3つの表記を互いに合わせて求め直すカラーピッカー — 計測 累積面積 10.797% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
PRESETS7色とhexField・rgbFieldが表記を合わせる
applyは7色の間を線形補間した色相・彩度・明度からhslToRgbでRGBを求め、そのRGBをHEX・RGB・HSLの3つの文字列に変換してhexField・rgbField・hslFieldへ同時に書き込みます。
function apply(t) {
var idx = Math.min(PRESETS.length - 2, Math.floor(t / SEG_MS));
var frac = (t % SEG_MS) / SEG_MS;
var a = PRESETS[idx];
var b = PRESETS[idx + 1];
var h = a.h + (b.h - a.h) * frac;
var s = a.s + (b.s - a.s) * frac;
var l = a.l + (b.l - a.l) * frac;
var hMod = h % 360;
var rgb = hslToRgb(h, s, l);
var hex = '#' + toHex(rgb[0]) + toHex(rgb[1]) + toHex(rgb[2]);
var hslStr = 'hsl(' + hMod.toFixed(0) + 'deg ' + s.toFixed(0) + '% ' + l.toFixed(0) + '%)';
var rgbStr = 'rgb(' + rgb[0] + ', ' + rgb[1] + ', ' + rgb[2] + ')';
swatchEl.style.background = hslStr;
hexField.textContent = hex;
rgbField.textContent = rgbStr;
hslField.textContent = hslStr;
}
06HSLスライダー — 色相の値に応じてトラックのグラデーションを描き直すカラーピッカー
色相の値をGRADIENT_STEPS20段階に分けてbuildGradientでトラックのグラデーションを描き直し、色相・彩度・明度3つのつまみの位置とプレビュー色を毎フレーム合わせ直すカラーピッカーです。

<main class="stage">
<section class="panel">
<div class="preview" id="preview"></div>
<div class="row">
<span class="rowLabel">色相</span>
<div class="track hueTrack" id="hTrack">
<div class="thumb" id="hThumb" role="slider" aria-label="色相" aria-valuemin="0" aria-valuemax="360" aria-valuenow="0"></div>
</div>
</div>
<div class="row">
<span class="rowLabel">彩度</span>
<div class="track" id="sTrack">
<div class="thumb" id="sThumb" role="slider" aria-label="彩度" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0"></div>
</div>
</div>
<div class="row">
<span class="rowLabel">明度</span>
<div class="track" id="lTrack">
<div class="thumb" id="lThumb" role="slider" aria-label="明度" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0"></div>
</div>
</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.panel {
display: flex;
flex-direction: column;
align-items: stretch;
gap: $sp-3;
width: 100%;
max-width: 220px;
}
.preview {
height: 48px;
border-radius: $r-card;
box-shadow: $shadow-raised;
}
.row {
display: flex;
align-items: center;
gap: $sp-2;
}
.rowLabel {
flex: 0 0 auto;
width: 30px;
color: $subject-ink;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 500;
font-size: 11px;
line-height: 1.3;
}
.track {
position: relative;
flex: 1 1 auto;
height: 10px;
border-radius: $r-pill;
background: rgba($subject-ink, .1);
box-shadow: inset 0 0 0 1px rgba($subject-ink, .08);
}
.hueTrack {
background: linear-gradient(
to right,
hsl(0, 100%, 50%), hsl(60, 100%, 50%), hsl(120, 100%, 50%),
hsl(180, 100%, 50%), hsl(240, 100%, 50%), hsl(300, 100%, 50%), hsl(360, 100%, 50%)
);
}
.thumb {
position: absolute;
top: 50%;
left: 0;
width: 16px;
height: 16px;
border-radius: $r-pill;
background: $stage-paper;
border: 2px solid $subject-blue;
box-shadow: $shadow-press;
}
@media (max-width: 340px) {
.panel { max-width: 168px; }
.preview { height: 40px; }
.rowLabel { width: 24px; font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.thumb { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.panel {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 12px;
width: 100%;
max-width: 220px;
}
.preview {
height: 48px;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.row {
display: flex;
align-items: center;
gap: 8px;
}
.rowLabel {
flex: 0 0 auto;
width: 30px;
color: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 500;
font-size: 11px;
line-height: 1.3;
}
.track {
position: relative;
flex: 1 1 auto;
height: 10px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.1);
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.08);
}
.hueTrack {
background: linear-gradient(to right, hsl(0, 100%, 50%), hsl(60, 100%, 50%), hsl(120, 100%, 50%), hsl(180, 100%, 50%), hsl(240, 100%, 50%), hsl(300, 100%, 50%), hsl(0, 100%, 50%));
}
.thumb {
position: absolute;
top: 50%;
left: 0;
width: 16px;
height: 16px;
border-radius: 999px;
background: #ffffff;
border: 2px solid #2f6df6;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
@media (max-width: 340px) {
.panel {
max-width: 168px;
}
.preview {
height: 40px;
}
.rowLabel {
width: 24px;
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.thumb {
transition: none;
}
}// HSL 슬라이더(06) — 色相 슬라이더 값이 바뀔 때마다 彩度·明度 트랙의 배경 그라디언트를
// GRADIENT_STEPS 個 정지점으로 다시 계산해 그린다. 실키·실서버 요청 없음.
(function () {
var previewEl = document.getElementById('preview');
var hTrack = document.getElementById('hTrack');
var sTrack = document.getElementById('sTrack');
var lTrack = document.getElementById('lTrack');
var hThumb = document.getElementById('hThumb');
var sThumb = document.getElementById('sThumb');
var lThumb = document.getElementById('lThumb');
if (!previewEl || !hTrack || !sTrack || !lTrack || !hThumb || !sThumb || !lThumb) return;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var CYCLE_MS = 3000;
var TRACK_W = 156;
var GRADIENT_STEPS = 20;
function buildGradient(stopFn) {
var stops = [];
for (var i = 0; i < GRADIENT_STEPS; i++) {
var ratio = i / (GRADIENT_STEPS - 1);
stops.push(stopFn(ratio) + ' ' + (ratio * 100).toFixed(1) + '%');
}
return 'linear-gradient(to right, ' + stops.join(', ') + ')';
}
function setThumb(thumbEl, ratio) {
var x = ratio * TRACK_W;
thumbEl.style.transform = 'translate(calc(-50% + ' + x.toFixed(2) + 'px), -50%)';
}
function apply(t) {
var p = t / CYCLE_MS;
var hue = (p * 360) % 360;
var sat = 55 + Math.sin(p * Math.PI * 2 * 1.3) * 35;
var light = 50 + Math.sin(p * Math.PI * 2 * .9 + 1) * 25;
setThumb(hThumb, hue / 360);
setThumb(sThumb, sat / 100);
setThumb(lThumb, light / 100);
sTrack.style.background = buildGradient(function (r) {
return 'hsl(' + hue.toFixed(1) + 'deg ' + (r * 100).toFixed(1) + '% 50%)';
});
lTrack.style.background = buildGradient(function (r) {
return 'hsl(' + hue.toFixed(1) + 'deg ' + sat.toFixed(1) + '% ' + (r * 100).toFixed(1) + '%)';
});
previewEl.style.background = 'hsl(' + hue.toFixed(1) + 'deg ' + sat.toFixed(1) + '% ' + light.toFixed(1) + '%)';
hThumb.setAttribute('aria-valuenow', String(Math.round(hue)));
sThumb.setAttribute('aria-valuenow', String(Math.round(sat)));
lThumb.setAttribute('aria-valuenow', String(Math.round(light)));
}
if (reduce) {
apply(0);
return;
}
var start = null;
function loop(ts) {
if (start === null) start = ts;
var t = (ts - start) % CYCLE_MS;
apply(t);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();import { useEffect, useRef } from "react";
import styles from "./ColorPickerHslSliders.module.scss";
// HSL 슬라이더 — 색조 슬라이더 값이 바뀔 때마다 채도·명도 트랙의 배경 그라디언트를
// GRADIENT_STEPS 개 정지점으로 다시 계산해 그린다. 실키·실서버 요청 없음.
const CYCLE_MS = 3000;
const TRACK_W = 156;
const GRADIENT_STEPS = 20;
function buildGradient(stopFn: (ratio: number) => string) {
const stops: string[] = [];
for (let i = 0; i < GRADIENT_STEPS; i++) {
const ratio = i / (GRADIENT_STEPS - 1);
stops.push(`${stopFn(ratio)} ${(ratio * 100).toFixed(1)}%`);
}
return `linear-gradient(to right, ${stops.join(", ")})`;
}
export default function ColorPickerHslSliders() {
const previewRef = useRef<HTMLDivElement>(null);
const sTrackRef = useRef<HTMLDivElement>(null);
const lTrackRef = useRef<HTMLDivElement>(null);
const hThumbRef = useRef<HTMLDivElement>(null);
const sThumbRef = useRef<HTMLDivElement>(null);
const lThumbRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const preview = previewRef.current;
const sTrack = sTrackRef.current;
const lTrack = lTrackRef.current;
const hThumb = hThumbRef.current;
const sThumb = sThumbRef.current;
const lThumb = lThumbRef.current;
if (!preview || !sTrack || !lTrack || !hThumb || !sThumb || !lThumb) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
function setThumb(thumbEl: HTMLDivElement, ratio: number) {
const x = ratio * TRACK_W;
thumbEl.style.transform = `translate(calc(-50% + ${x.toFixed(2)}px), -50%)`;
}
function apply(t: number) {
const p = t / CYCLE_MS;
const hue = (p * 360) % 360;
const sat = 55 + Math.sin(p * Math.PI * 2 * 1.3) * 35;
const light = 50 + Math.sin(p * Math.PI * 2 * .9 + 1) * 25;
setThumb(hThumb!, hue / 360);
setThumb(sThumb!, sat / 100);
setThumb(lThumb!, light / 100);
sTrack!.style.background = buildGradient((r) => `hsl(${hue.toFixed(1)}deg ${(r * 100).toFixed(1)}% 50%)`);
lTrack!.style.background = buildGradient((r) => `hsl(${hue.toFixed(1)}deg ${sat.toFixed(1)}% ${(r * 100).toFixed(1)}%)`);
preview!.style.background = `hsl(${hue.toFixed(1)}deg ${sat.toFixed(1)}% ${light.toFixed(1)}%)`;
hThumb!.setAttribute("aria-valuenow", String(Math.round(hue)));
sThumb!.setAttribute("aria-valuenow", String(Math.round(sat)));
lThumb!.setAttribute("aria-valuenow", String(Math.round(light)));
}
if (reduce) {
apply(0);
return;
}
let start: number | null = null;
let raf = 0;
function loop(ts: number) {
if (start === null) start = ts;
const t = (ts - start) % CYCLE_MS;
apply(t);
raf = requestAnimationFrame(loop);
}
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}, []);
return (
<div className={styles.stage}>
<section className={styles.panel}>
<div className={styles.preview} ref={previewRef} />
<div className={styles.row}>
<span className={styles.rowLabel}>색조</span>
<div className={`${styles.track} ${styles.hueTrack}`}>
<div className={styles.thumb} ref={hThumbRef} role="slider" aria-label="색조" aria-valuemin={0} aria-valuemax={360} aria-valuenow={0} />
</div>
</div>
<div className={styles.row}>
<span className={styles.rowLabel}>채도</span>
<div className={styles.track} ref={sTrackRef}>
<div className={styles.thumb} ref={sThumbRef} role="slider" aria-label="채도" aria-valuemin={0} aria-valuemax={100} aria-valuenow={0} />
</div>
</div>
<div className={styles.row}>
<span className={styles.rowLabel}>명도</span>
<div className={styles.track} ref={lTrackRef}>
<div className={styles.thumb} ref={lThumbRef} role="slider" aria-label="명도" aria-valuemin={0} aria-valuemax={100} aria-valuenow={0} />
</div>
</div>
</section>
</div>
);
}06 · HSLスライダー — 色相の値に応じてトラックのグラデーションを描き直すカラーピッカー — 計測 累積面積 15.53% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
GRADIENT_STEPS20がbuildGradientでトラックの階調を決める
buildGradientはGRADIENT_STEPS回のループでグラデーションの色止まりを積み上げてlinear-gradientの文字列を作り、applyが色相が変わるたびに彩度・明度それぞれのトラックをこの関数で描き直します。
function apply(t) {
var p = t / CYCLE_MS;
var hue = (p * 360) % 360;
var sat = 55 + Math.sin(p * Math.PI * 2 * 1.3) * 35;
var light = 50 + Math.sin(p * Math.PI * 2 * .9 + 1) * 25;
setThumb(hThumb, hue / 360);
setThumb(sThumb, sat / 100);
setThumb(lThumb, light / 100);
sTrack.style.background = buildGradient(function (r) {
return 'hsl(' + hue.toFixed(1) + 'deg ' + (r * 100).toFixed(1) + '% 50%)';
});
lTrack.style.background = buildGradient(function (r) {
return 'hsl(' + hue.toFixed(1) + 'deg ' + sat.toFixed(1) + '% ' + (r * 100).toFixed(1) + '%)';
});
previewEl.style.background = 'hsl(' + hue.toFixed(1) + 'deg ' + sat.toFixed(1) + '% ' + light.toFixed(1) + '%)';
hThumb.setAttribute('aria-valuenow', String(Math.round(hue)));
sThumb.setAttribute('aria-valuenow', String(Math.round(sat)));
lThumb.setAttribute('aria-valuenow', String(Math.round(light)));
}
07スウォッチチップ — 明るさの値でチェックアイコンの対比を合わせるカラーピッカー
SEG_MS400msごとにSWATCHES6色の間を離散的に切り替え、選択中の色の明るさが150を超えるとチェックアイコンを暗く、下回ると明るく切り替えてチップとの対比を合わせ直すカラーピッカーです。

<main class="stage">
<section class="panel" role="radiogroup" aria-label="カラースウォッチ">
<span class="chip" data-i="0" role="radio" aria-checked="false" aria-label="スウォッチ1">
<span class="check"></span>
</span>
<span class="chip" data-i="1" role="radio" aria-checked="false" aria-label="スウォッチ2">
<span class="check"></span>
</span>
<span class="chip" data-i="2" role="radio" aria-checked="false" aria-label="スウォッチ3">
<span class="check"></span>
</span>
<span class="chip" data-i="3" role="radio" aria-checked="false" aria-label="スウォッチ4">
<span class="check"></span>
</span>
<span class="chip" data-i="4" role="radio" aria-checked="false" aria-label="スウォッチ5">
<span class="check"></span>
</span>
<span class="chip" data-i="5" role="radio" aria-checked="false" aria-label="スウォッチ6">
<span class="check"></span>
</span>
</section>
<div class="readout" id="readout">
<span class="hex" id="hex">#000000</span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.panel {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: $sp-3;
max-width: 220px;
margin: 0 0 $sp-4;
}
.chip {
position: relative;
width: 44px;
height: 44px;
border-radius: $r-control;
box-shadow: $shadow-press;
display: flex;
align-items: center;
justify-content: center;
opacity: .55;
transform: scale(.88);
&.is-selected {
opacity: 1;
transform: scale(1);
box-shadow: $shadow-raised;
}
}
.check {
width: 8px;
height: 14px;
border-right: 2px solid $subject-cream;
border-bottom: 2px solid $subject-cream;
transform: rotate(45deg) translate(-1px, -2px);
opacity: 0;
.is-selected & {
opacity: 1;
}
.is-selected.is-ink & {
border-color: $subject-ink;
}
}
.readout {
display: flex;
align-items: center;
justify-content: center;
padding: $sp-2 $sp-4;
border-radius: $r-pill;
color: $subject-cream;
&.is-ink {
color: $subject-ink;
}
}
.hex {
font-family: "Space Mono", ui-monospace, monospace;
font-weight: 700;
font-size: 13px;
line-height: 1.3;
}
@media (max-width: 340px) {
.panel { max-width: 168px; gap: $sp-2; }
.chip { width: 36px; height: 36px; }
}
@media (prefers-reduced-motion: reduce) {
.chip,
.check { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.panel {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 12px;
max-width: 220px;
margin: 0 0 16px;
}
.chip {
position: relative;
width: 44px;
height: 44px;
border-radius: 12px;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
display: flex;
align-items: center;
justify-content: center;
opacity: 0.55;
transform: scale(0.88);
}
.chip.is-selected {
opacity: 1;
transform: scale(1);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.check {
width: 8px;
height: 14px;
border-right: 2px solid #fff7e6;
border-bottom: 2px solid #fff7e6;
transform: rotate(45deg) translate(-1px, -2px);
opacity: 0;
}
.is-selected .check {
opacity: 1;
}
.is-selected.is-ink .check {
border-color: #17141a;
}
.readout {
display: flex;
align-items: center;
justify-content: center;
padding: 8px 16px;
border-radius: 999px;
color: #fff7e6;
}
.readout.is-ink {
color: #17141a;
}
.hex {
font-family: "Space Mono", ui-monospace, monospace;
font-weight: 700;
font-size: 13px;
line-height: 1.3;
}
@media (max-width: 340px) {
.panel {
max-width: 168px;
gap: 8px;
}
.chip {
width: 36px;
height: 36px;
}
}
@media (prefers-reduced-motion: reduce) {
.chip,
.check {
transition: none;
}
}// 스와치 칩(07) — 각 칩 색의 밝기 값을 계산해, 밝은 색엔 어두운 체크 아이콘이
// 어두운 색엔 밝은 체크 아이콘이 뜨도록 대비를 매번 다시 맞춘다. 실키·실서버 요청 없음.
(function () {
var panelEl = document.querySelector('.panel');
var readoutEl = document.getElementById('readout');
var hexEl = document.getElementById('hex');
var chipEls = panelEl ? panelEl.querySelectorAll('.chip') : [];
if (!panelEl || !readoutEl || !hexEl || chipEls.length !== 6) return;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var CYCLE_MS = 2400;
var SWATCHES = [
{ h: 6, s: 78, l: 56 },
{ h: 46, s: 85, l: 62 },
{ h: 150, s: 45, l: 32 },
{ h: 200, s: 70, l: 46 },
{ h: 265, s: 40, l: 78 },
{ h: 320, s: 55, l: 22 }
];
var ORDER = [0, 1, 2, 3, 4, 5, 0];
var SEG_MS = CYCLE_MS / (ORDER.length - 1);
function hslToRgb(h, s, l) {
s /= 100; l /= 100;
var k = function (n) { return (n + h / 30) % 12; };
var a = s * Math.min(l, 1 - l);
var f = function (n) { return l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1))); };
return [Math.round(f(0) * 255), Math.round(f(8) * 255), Math.round(f(4) * 255)];
}
function toHex(n) {
return n.toString(16).padStart(2, '0');
}
var HEX = SWATCHES.map(function (c) {
var rgb = hslToRgb(c.h, c.s, c.l);
return { hex: '#' + toHex(rgb[0]) + toHex(rgb[1]) + toHex(rgb[2]), rgb: rgb };
});
SWATCHES.forEach(function (c, i) {
chipEls[i].style.background = 'hsl(' + c.h + 'deg ' + c.s + '% ' + c.l + '%)';
});
function apply(t) {
var idx = Math.min(ORDER.length - 2, Math.floor(t / SEG_MS));
var frac = (t % SEG_MS) / SEG_MS;
var selIndex = ORDER[frac < .5 ? idx : idx + 1];
for (var i = 0; i < chipEls.length; i++) {
var isSel = i === selIndex;
var rgb = HEX[i].rgb;
var brightness = (rgb[0] * 299 + rgb[1] * 587 + rgb[2] * 114) / 1000;
var ink = brightness > 150;
chipEls[i].classList.toggle('is-selected', isSel);
chipEls[i].classList.toggle('is-ink', ink);
chipEls[i].setAttribute('aria-checked', isSel ? 'true' : 'false');
}
hexEl.textContent = HEX[selIndex].hex;
readoutEl.style.background = chipEls[selIndex].style.background;
var selBrightness = (HEX[selIndex].rgb[0] * 299 + HEX[selIndex].rgb[1] * 587 + HEX[selIndex].rgb[2] * 114) / 1000;
readoutEl.classList.toggle('is-ink', selBrightness > 150);
}
if (reduce) {
apply(0);
return;
}
var start = null;
function loop(ts) {
if (start === null) start = ts;
var t = (ts - start) % CYCLE_MS;
apply(t);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();import { useEffect, useRef } from "react";
import styles from "./ColorPickerSwatchChips.module.scss";
// 스와치 칩 — 각 칩 색의 밝기 값을 계산해, 밝은 색엔 어두운 체크 아이콘이
// 어두운 색엔 밝은 체크 아이콘이 뜨도록 대비를 매번 다시 맞춘다. 실키·실서버 요청 없음.
const CYCLE_MS = 2400;
const SWATCHES = [
{ h: 6, s: 78, l: 56 },
{ h: 46, s: 85, l: 62 },
{ h: 150, s: 45, l: 32 },
{ h: 200, s: 70, l: 46 },
{ h: 265, s: 40, l: 78 },
{ h: 320, s: 55, l: 22 },
];
const ORDER = [0, 1, 2, 3, 4, 5, 0];
const SEG_MS = CYCLE_MS / (ORDER.length - 1);
function hslToRgb(h: number, s: number, l: number): [number, number, number] {
s /= 100;
l /= 100;
const k = (n: number) => (n + h / 30) % 12;
const a = s * Math.min(l, 1 - l);
const f = (n: number) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
return [Math.round(f(0) * 255), Math.round(f(8) * 255), Math.round(f(4) * 255)];
}
function toHex(n: number) {
return n.toString(16).padStart(2, "0");
}
const HEX = SWATCHES.map((c) => {
const rgb = hslToRgb(c.h, c.s, c.l);
return { hex: "#" + toHex(rgb[0]) + toHex(rgb[1]) + toHex(rgb[2]), rgb };
});
export default function ColorPickerSwatchChips() {
const panelRef = useRef<HTMLDivElement>(null);
const chip0 = useRef<HTMLSpanElement>(null);
const chip1 = useRef<HTMLSpanElement>(null);
const chip2 = useRef<HTMLSpanElement>(null);
const chip3 = useRef<HTMLSpanElement>(null);
const chip4 = useRef<HTMLSpanElement>(null);
const chip5 = useRef<HTMLSpanElement>(null);
const readoutRef = useRef<HTMLDivElement>(null);
const hexRef = useRef<HTMLSpanElement>(null);
useEffect(() => {
const panel = panelRef.current;
const readout = readoutRef.current;
const hexEl = hexRef.current;
const chips = [chip0.current, chip1.current, chip2.current, chip3.current, chip4.current, chip5.current];
if (!panel || !readout || !hexEl || chips.some((c) => !c)) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
function apply(t: number) {
const idx = Math.min(ORDER.length - 2, Math.floor(t / SEG_MS));
const frac = (t % SEG_MS) / SEG_MS;
const selIndex = ORDER[frac < .5 ? idx : idx + 1];
chips.forEach((chip, i) => {
const isSel = i === selIndex;
const rgb = HEX[i].rgb;
const brightness = (rgb[0] * 299 + rgb[1] * 587 + rgb[2] * 114) / 1000;
const ink = brightness > 150;
chip!.classList.toggle(styles.isSelected, isSel);
chip!.classList.toggle(styles.isInk, ink);
chip!.setAttribute("aria-checked", isSel ? "true" : "false");
});
hexEl!.textContent = HEX[selIndex].hex;
readout!.style.background = `hsl(${SWATCHES[selIndex].h}deg ${SWATCHES[selIndex].s}% ${SWATCHES[selIndex].l}%)`;
const selBrightness = (HEX[selIndex].rgb[0] * 299 + HEX[selIndex].rgb[1] * 587 + HEX[selIndex].rgb[2] * 114) / 1000;
readout!.classList.toggle(styles.isInk, selBrightness > 150);
}
if (reduce) {
apply(0);
return;
}
let start: number | null = null;
let raf = 0;
function loop(ts: number) {
if (start === null) start = ts;
const t = (ts - start) % CYCLE_MS;
apply(t);
raf = requestAnimationFrame(loop);
}
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}, []);
const chipRefs = [chip0, chip1, chip2, chip3, chip4, chip5];
return (
<div className={styles.stage}>
<div className={styles.panel} ref={panelRef} role="radiogroup" aria-label="색상 스와치">
{chipRefs.map((r, i) => (
<span
key={i}
className={styles.chip}
ref={r}
role="radio"
aria-checked="false"
aria-label={`스와치 ${i + 1}`}
style={{ background: `hsl(${SWATCHES[i].h}deg ${SWATCHES[i].s}% ${SWATCHES[i].l}%)` }}
>
<span className={styles.check} />
</span>
))}
</div>
<div className={styles.readout} ref={readoutRef}>
<span className={styles.hex} ref={hexRef}>#000000</span>
</div>
</div>
);
}07 · スウォッチチップ — 明るさの値でチェックアイコンの対比を合わせるカラーピッカー — 計測 累積面積 9.408% · 動いたフレーム 7/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
SWATCHES6色とaria-checkedがチェックアイコンの対比を合わせる
applyは6つのチップそれぞれの明るさをforループで調べてis-inkの付け替えとaria-checkedの更新を同時に行い、選択中のチップだけ読み出し欄の背景色も合わせて書き換えます。
function apply(t) {
var idx = Math.min(ORDER.length - 2, Math.floor(t / SEG_MS));
var frac = (t % SEG_MS) / SEG_MS;
var selIndex = ORDER[frac < .5 ? idx : idx + 1];
for (var i = 0; i < chipEls.length; i++) {
var isSel = i === selIndex;
var rgb = HEX[i].rgb;
var brightness = (rgb[0] * 299 + rgb[1] * 587 + rgb[2] * 114) / 1000;
var ink = brightness > 150;
chipEls[i].classList.toggle('is-selected', isSel);
chipEls[i].classList.toggle('is-ink', ink);
chipEls[i].setAttribute('aria-checked', isSel ? 'true' : 'false');
}
hexEl.textContent = HEX[selIndex].hex;
readoutEl.style.background = chipEls[selIndex].style.background;
var selBrightness = (HEX[selIndex].rgb[0] * 299 + HEX[selIndex].rgb[1] * 587 + HEX[selIndex].rgb[2] * 114) / 1000;
readoutEl.classList.toggle('is-ink', selBrightness > 150);
}
08スポイト — 抽出された色の明るさでプレビューを求め直すカラーピッカー
三角波の軌跡でキャンバス上を移動するルーペの座標からgetImageDataで1ピクセルの色を実際に読み取り、その明るさに応じてプレビューの枠の太さと文字色を求め直すカラーピッカーです。

<main class="stage">
<section class="panel">
<div class="screenWrap">
<canvas class="screen" id="screen" width="160" height="90" aria-hidden="true"></canvas>
<div class="loupe" id="loupe"></div>
</div>
<div class="preview" id="preview">
<span class="hex" id="hex">#000000</span>
</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-orange);
.panel {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-4;
}
.screenWrap {
position: relative;
width: 160px;
height: 90px;
border-radius: $r-sm;
overflow: hidden;
box-shadow: $shadow-raised;
}
.screen {
display: block;
width: 160px;
height: 90px;
}
.loupe {
position: absolute;
top: 0;
left: 0;
width: 20px;
height: 20px;
border-radius: $r-pill;
border: 2px solid $subject-cream;
box-shadow: $shadow-press;
pointer-events: none;
}
.preview {
display: flex;
align-items: center;
justify-content: center;
width: 96px;
height: 40px;
border-radius: $r-control;
border-style: solid;
border-color: $subject-cream;
color: $subject-cream;
box-shadow: $shadow-press;
&.is-ink {
color: $subject-ink;
border-color: $subject-ink;
}
}
.hex {
font-family: "Space Mono", ui-monospace, monospace;
font-weight: 700;
font-size: 12px;
line-height: 1.3;
}
@media (prefers-reduced-motion: reduce) {
.loupe,
.preview { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.panel {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.screenWrap {
position: relative;
width: 160px;
height: 90px;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.screen {
display: block;
width: 160px;
height: 90px;
}
.loupe {
position: absolute;
top: 0;
left: 0;
width: 20px;
height: 20px;
border-radius: 999px;
border: 2px solid #fff7e6;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
pointer-events: none;
}
.preview {
display: flex;
align-items: center;
justify-content: center;
width: 96px;
height: 40px;
border-radius: 12px;
border-style: solid;
border-color: #fff7e6;
color: #fff7e6;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.preview.is-ink {
color: #17141a;
border-color: #17141a;
}
.hex {
font-family: "Space Mono", ui-monospace, monospace;
font-weight: 700;
font-size: 12px;
line-height: 1.3;
}
@media (prefers-reduced-motion: reduce) {
.loupe,
.preview {
transition: none;
}
}// 스포이드(08) — 캔버스에서 커서 위치의 실제 픽셀 값을 getImageData 로 추출해
// 밝기 값을 계산하고, 그 값에 따라 プレビュー 글자색과 테두리 두께를 함께 다시 정한다.
// 브라우저 EyeDropper API 는 지원 범위가 좁아 쓰지 않는다 — 캔버스 픽셀 샘플링 시뮬레이션.
// 실키·실서버 요청 없음.
(function () {
var screenEl = document.getElementById('screen');
var loupeEl = document.getElementById('loupe');
var previewEl = document.getElementById('preview');
var hexEl = document.getElementById('hex');
if (!screenEl || !loupeEl || !previewEl || !hexEl || !screenEl.getContext) return;
var ctx = screenEl.getContext('2d');
if (!ctx) return;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var CANVAS_W = 160;
var CANVAS_H = 90;
var CYCLE_MS = 2600;
var MIN_BORDER = 1;
var MAX_BORDER = 6;
var BANDS = [
{ x0: 0, x1: 40, h: 10, s: 70, l: 55 },
{ x0: 40, x1: 80, h: 150, s: 50, l: 30 },
{ x0: 80, x1: 120, h: 200, s: 60, l: 70 },
{ x0: 120, x1: 160, h: 280, s: 45, l: 45 }
];
BANDS.forEach(function (b) {
ctx.fillStyle = 'hsl(' + b.h + 'deg ' + b.s + '% ' + b.l + '%)';
ctx.fillRect(b.x0, 0, b.x1 - b.x0, CANVAS_H);
});
function toHex(n) {
return n.toString(16).padStart(2, '0');
}
function apply(t) {
var p = (t / CYCLE_MS) % 1;
var triangle = p < .5 ? p * 2 : (1 - p) * 2;
var x = 8 + triangle * (CANVAS_W - 16);
var y = CANVAS_H / 2 + Math.sin(p * Math.PI * 4) * (CANVAS_H / 2 - 6);
var xi = Math.max(0, Math.min(CANVAS_W - 1, Math.round(x)));
var yi = Math.max(0, Math.min(CANVAS_H - 1, Math.round(y)));
var pixel = ctx.getImageData(xi, yi, 1, 1).data;
var r = pixel[0], g = pixel[1], b = pixel[2];
var hex = '#' + toHex(r) + toHex(g) + toHex(b);
var brightness = (r * 299 + g * 587 + b * 114) / 1000;
var ink = brightness > 150;
var borderPx = MIN_BORDER + (brightness / 255) * (MAX_BORDER - MIN_BORDER);
loupeEl.style.transform = 'translate(-50%, -50%) translate(' + x.toFixed(2) + 'px, ' + y.toFixed(2) + 'px)';
previewEl.style.background = hex;
previewEl.style.borderWidth = borderPx.toFixed(1) + 'px';
previewEl.classList.toggle('is-ink', ink);
hexEl.textContent = hex;
}
if (reduce) {
apply(0);
return;
}
var start = null;
function loop(ts) {
if (start === null) start = ts;
var t = (ts - start) % CYCLE_MS;
apply(t);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();import { useEffect, useRef } from "react";
import styles from "./ColorPickerEyedropper.module.scss";
// 스포이드 — 캔버스에서 커서 위치의 실제 픽셀 값을 getImageData 로 추출해 밝기 값을
// 계산하고, 그 값에 따라 미리보기 글자색과 테두리 두께를 함께 다시 정한다.
// 브라우저 스포이드 API는 지원 범위가 좁아 쓰지 않는다 — 캔버스 픽셀 샘플링 시뮬레이션.
// 실키·실서버 요청 없음.
const CANVAS_W = 160;
const CANVAS_H = 90;
const CYCLE_MS = 2600;
const MIN_BORDER = 1;
const MAX_BORDER = 6;
const BANDS = [
{ x0: 0, x1: 40, h: 10, s: 70, l: 55 },
{ x0: 40, x1: 80, h: 150, s: 50, l: 30 },
{ x0: 80, x1: 120, h: 200, s: 60, l: 70 },
{ x0: 120, x1: 160, h: 280, s: 45, l: 45 },
];
function toHex(n: number) {
return n.toString(16).padStart(2, "0");
}
export default function ColorPickerEyedropper() {
const screenRef = useRef<HTMLCanvasElement>(null);
const loupeRef = useRef<HTMLDivElement>(null);
const previewRef = useRef<HTMLDivElement>(null);
const hexRef = useRef<HTMLSpanElement>(null);
useEffect(() => {
const screen = screenRef.current;
const loupe = loupeRef.current;
const preview = previewRef.current;
const hexEl = hexRef.current;
if (!screen || !loupe || !preview || !hexEl) return;
const ctx = screen.getContext("2d");
if (!ctx) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
BANDS.forEach((b) => {
ctx.fillStyle = `hsl(${b.h}deg ${b.s}% ${b.l}%)`;
ctx.fillRect(b.x0, 0, b.x1 - b.x0, CANVAS_H);
});
function apply(t: number) {
const p = (t / CYCLE_MS) % 1;
const triangle = p < .5 ? p * 2 : (1 - p) * 2;
const x = 8 + triangle * (CANVAS_W - 16);
const y = CANVAS_H / 2 + Math.sin(p * Math.PI * 4) * (CANVAS_H / 2 - 6);
const xi = Math.max(0, Math.min(CANVAS_W - 1, Math.round(x)));
const yi = Math.max(0, Math.min(CANVAS_H - 1, Math.round(y)));
const pixel = ctx!.getImageData(xi, yi, 1, 1).data;
const r = pixel[0], g = pixel[1], b = pixel[2];
const hex = "#" + toHex(r) + toHex(g) + toHex(b);
const brightness = (r * 299 + g * 587 + b * 114) / 1000;
const ink = brightness > 150;
const borderPx = MIN_BORDER + (brightness / 255) * (MAX_BORDER - MIN_BORDER);
loupe!.style.transform = `translate(-50%, -50%) translate(${x.toFixed(2)}px, ${y.toFixed(2)}px)`;
preview!.style.background = hex;
preview!.style.borderWidth = `${borderPx.toFixed(1)}px`;
preview!.classList.toggle(styles.isInk, ink);
hexEl!.textContent = hex;
}
if (reduce) {
apply(0);
return;
}
let start: number | null = null;
let raf = 0;
function loop(ts: number) {
if (start === null) start = ts;
const t = (ts - start) % CYCLE_MS;
apply(t);
raf = requestAnimationFrame(loop);
}
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}, []);
return (
<div className={styles.stage}>
<section className={styles.panel}>
<div className={styles.screenWrap}>
<canvas className={styles.screen} ref={screenRef} width={CANVAS_W} height={CANVAS_H} aria-hidden="true" />
<div className={styles.loupe} ref={loupeRef} />
</div>
<div className={styles.preview} ref={previewRef}>
<span className={styles.hex} ref={hexRef}>#000000</span>
</div>
</section>
</div>
);
}08 · スポイト — 抽出された色の明るさでプレビューを求め直すカラーピッカー — 計測 累積面積 8.365% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
getImageDataが抽出したピクセルの明るさでプレビューを求め直す
applyはctx.getImageDataが返したピクセルのRGB値から明るさを求め、その値をMIN_BORDER1からMAX_BORDER6までの範囲に写してプレビューの枠の太さに、150を超えるかどうかを文字色の切り替えにそれぞれ使います。
function apply(t) {
var p = (t / CYCLE_MS) % 1;
var triangle = p < .5 ? p * 2 : (1 - p) * 2;
var x = 8 + triangle * (CANVAS_W - 16);
var y = CANVAS_H / 2 + Math.sin(p * Math.PI * 4) * (CANVAS_H / 2 - 6);
var xi = Math.max(0, Math.min(CANVAS_W - 1, Math.round(x)));
var yi = Math.max(0, Math.min(CANVAS_H - 1, Math.round(y)));
var pixel = ctx.getImageData(xi, yi, 1, 1).data;
var r = pixel[0], g = pixel[1], b = pixel[2];
var hex = '#' + toHex(r) + toHex(g) + toHex(b);
var brightness = (r * 299 + g * 587 + b * 114) / 1000;
var ink = brightness > 150;
var borderPx = MIN_BORDER + (brightness / 255) * (MAX_BORDER - MIN_BORDER);
loupeEl.style.transform = 'translate(-50%, -50%) translate(' + x.toFixed(2) + 'px, ' + y.toFixed(2) + 'px)';
previewEl.style.background = hex;
previewEl.style.borderWidth = borderPx.toFixed(1) + 'px';
previewEl.classList.toggle('is-ink', ink);
hexEl.textContent = hex;
}
09履歴色 — 色同士の距離を計算し近い色をまとめるリスト型カラーピッカー
10色のRAW配列それぞれの間のユークリッド距離をdistで計算し、その距離がMERGE_DIST30より近ければ同じグループにまとめて、統合後は10色から7色になったことを表示するカラーピッカーです。

<main class="stage">
<section class="panel">
<div class="grid" id="grid" role="list" aria-label="最近使用した色の一覧">
<span class="chip" data-i="0" role="img" aria-label="最近の色1"></span>
<span class="chip" data-i="1" role="img" aria-label="最近の色2"></span>
<span class="chip" data-i="2" role="img" aria-label="最近の色3"></span>
<span class="chip" data-i="3" role="img" aria-label="最近の色4"></span>
<span class="chip" data-i="4" role="img" aria-label="最近の色5"></span>
<span class="chip" data-i="5" role="img" aria-label="最近の色6"></span>
<span class="chip" data-i="6" role="img" aria-label="最近の色7"></span>
<span class="chip" data-i="7" role="img" aria-label="最近の色8"></span>
<span class="chip" data-i="8" role="img" aria-label="最近の色9"></span>
<span class="chip" data-i="9" role="img" aria-label="最近の色10"></span>
</div>
<p class="count" id="count">10個 → 10個</p>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-yellow);
.panel {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-4;
}
.grid {
position: relative;
width: 252px;
height: 96px;
}
.chip {
position: absolute;
top: 0;
left: 0;
width: 44px;
height: 44px;
border-radius: $r-sm;
box-shadow: $shadow-press;
will-change: transform, opacity;
}
.count {
margin: 0;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 13px;
font-weight: 700;
color: $subject-ink;
}
@media (prefers-reduced-motion: reduce) {
.chip { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.panel {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.grid {
position: relative;
width: 252px;
height: 96px;
}
.chip {
position: absolute;
top: 0;
left: 0;
width: 44px;
height: 44px;
border-radius: 8px;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
will-change: transform, opacity;
}
.count {
margin: 0;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 13px;
font-weight: 700;
color: #17141a;
}
@media (prefers-reduced-motion: reduce) {
.chip {
transition: none;
}
}// 최근 色相(09) — 목록 안 색상값 사이의 실제 거리를 계산해, 그 거리가 MERGE_DIST 보다
// 가까운 색끼리는 대표 하나로 합쳐 표시 개수를 줄인다. 실키·실서버 요청 없음.
(function () {
var gridEl = document.getElementById('grid');
var countEl = document.getElementById('count');
var chipEls = gridEl ? gridEl.querySelectorAll('.chip') : [];
if (!gridEl || !countEl || chipEls.length !== 10) return;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var MERGE_DIST = 30;
var CELL = 44;
var GAP = 8;
var STEP = CELL + GAP;
var COLS = 5;
var CYCLE_MS = 2800;
var RAW = [
{ h: 8, s: 70, l: 55 },
{ h: 12, s: 68, l: 57 },
{ h: 45, s: 80, l: 60 },
{ h: 200, s: 60, l: 50 },
{ h: 205, s: 58, l: 52 },
{ h: 140, s: 50, l: 40 },
{ h: 280, s: 45, l: 65 },
{ h: 283, s: 47, l: 63 },
{ h: 20, s: 65, l: 58 },
{ h: 320, s: 55, l: 50 }
];
function hslToRgb(h, s, l) {
s /= 100; l /= 100;
var k = function (n) { return (n + h / 30) % 12; };
var a = s * Math.min(l, 1 - l);
var f = function (n) { return l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1))); };
return [Math.round(f(0) * 255), Math.round(f(8) * 255), Math.round(f(4) * 255)];
}
function toHex(n) {
return n.toString(16).padStart(2, '0');
}
function dist(a, b) {
return Math.sqrt(
Math.pow(a[0] - b[0], 2) + Math.pow(a[1] - b[1], 2) + Math.pow(a[2] - b[2], 2)
);
}
var RGB = RAW.map(function (c) { return hslToRgb(c.h, c.s, c.l); });
var kept = [];
var groupOf = new Array(RAW.length);
for (var i = 0; i < RAW.length; i++) {
var found = -1;
for (var j = 0; j < kept.length; j++) {
if (dist(RGB[i], RGB[kept[j]]) < MERGE_DIST) { found = kept[j]; break; }
}
if (found === -1) {
kept.push(i);
groupOf[i] = i;
} else {
groupOf[i] = found;
}
}
function centerOf(i) {
var col = i % COLS;
var row = Math.floor(i / COLS);
return { x: col * STEP, y: row * STEP };
}
RAW.forEach(function (c, i) {
var rgb = RGB[i];
var hex = '#' + toHex(rgb[0]) + toHex(rgb[1]) + toHex(rgb[2]);
chipEls[i].style.background = hex;
});
countEl.textContent = RAW.length + '個 → ' + kept.length + '個';
function apply(t) {
var p = (t / CYCLE_MS) % 1;
var mergeAmount = p < .5 ? p * 2 : (1 - p) * 2;
for (var i = 0; i < RAW.length; i++) {
var own = centerOf(i);
if (groupOf[i] === i) {
var pulse = 1 + .3 * mergeAmount;
var tilt = mergeAmount * 10;
chipEls[i].style.transform = 'translate(' + own.x.toFixed(2) + 'px, ' + own.y.toFixed(2) + 'px) scale(' + pulse.toFixed(3) + ') rotate(' + tilt.toFixed(2) + 'deg)';
chipEls[i].style.opacity = '1';
} else {
var target = centerOf(groupOf[i]);
var x = own.x + (target.x - own.x) * mergeAmount;
var y = own.y + (target.y - own.y) * mergeAmount;
var scale = 1 - .95 * mergeAmount;
var opacity = 1 - .9 * mergeAmount;
chipEls[i].style.transform = 'translate(' + x.toFixed(2) + 'px, ' + y.toFixed(2) + 'px) scale(' + scale.toFixed(3) + ')';
chipEls[i].style.opacity = opacity.toFixed(3);
}
}
}
if (reduce) {
apply(0);
return;
}
var start = null;
function loop(ts) {
if (start === null) start = ts;
var t = (ts - start) % CYCLE_MS;
apply(t);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();import { useEffect, useRef } from "react";
import styles from "./ColorPickerRecentColors.module.scss";
// 최근 색상(09) — 목록 안 색상값 사이의 실제 거리를 계산해, 그 거리가 MERGE_DIST 보다
// 가까운 색끼리는 대표 하나로 합쳐 표시 개수를 줄인다. 실키·실서버 요청 없음.
const MERGE_DIST = 30;
const CELL = 44;
const GAP = 8;
const STEP = CELL + GAP;
const COLS = 5;
const CYCLE_MS = 2800;
const RAW = [
{ h: 8, s: 70, l: 55 },
{ h: 12, s: 68, l: 57 },
{ h: 45, s: 80, l: 60 },
{ h: 200, s: 60, l: 50 },
{ h: 205, s: 58, l: 52 },
{ h: 140, s: 50, l: 40 },
{ h: 280, s: 45, l: 65 },
{ h: 283, s: 47, l: 63 },
{ h: 20, s: 65, l: 58 },
{ h: 320, s: 55, l: 50 },
];
function hslToRgb(h: number, s: number, l: number): [number, number, number] {
s /= 100; l /= 100;
const k = (n: number) => (n + h / 30) % 12;
const a = s * Math.min(l, 1 - l);
const f = (n: number) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
return [Math.round(f(0) * 255), Math.round(f(8) * 255), Math.round(f(4) * 255)];
}
function toHex(n: number) {
return n.toString(16).padStart(2, "0");
}
function dist(a: number[], b: number[]) {
return Math.sqrt(
Math.pow(a[0] - b[0], 2) + Math.pow(a[1] - b[1], 2) + Math.pow(a[2] - b[2], 2)
);
}
const RECENT_LABELS = [
"최근 색상 1", "최근 색상 2", "최근 색상 3", "최근 색상 4", "최근 색상 5",
"최근 색상 6", "최근 색상 7", "최근 색상 8", "최근 색상 9", "최근 색상 10",
];
const RGB = RAW.map((c) => hslToRgb(c.h, c.s, c.l));
const kept: number[] = [];
const groupOf: number[] = new Array(RAW.length);
for (let i = 0; i < RAW.length; i++) {
let found = -1;
for (let j = 0; j < kept.length; j++) {
if (dist(RGB[i], RGB[kept[j]]) < MERGE_DIST) { found = kept[j]; break; }
}
if (found === -1) {
kept.push(i);
groupOf[i] = i;
} else {
groupOf[i] = found;
}
}
function centerOf(i: number) {
const col = i % COLS;
const row = Math.floor(i / COLS);
return { x: col * STEP, y: row * STEP };
}
export default function ColorPickerRecentColors() {
const chipRef0 = useRef<HTMLSpanElement>(null);
const chipRef1 = useRef<HTMLSpanElement>(null);
const chipRef2 = useRef<HTMLSpanElement>(null);
const chipRef3 = useRef<HTMLSpanElement>(null);
const chipRef4 = useRef<HTMLSpanElement>(null);
const chipRef5 = useRef<HTMLSpanElement>(null);
const chipRef6 = useRef<HTMLSpanElement>(null);
const chipRef7 = useRef<HTMLSpanElement>(null);
const chipRef8 = useRef<HTMLSpanElement>(null);
const chipRef9 = useRef<HTMLSpanElement>(null);
const countRef = useRef<HTMLParagraphElement>(null);
const chipRefs = [chipRef0, chipRef1, chipRef2, chipRef3, chipRef4, chipRef5, chipRef6, chipRef7, chipRef8, chipRef9];
useEffect(() => {
const chips = chipRefs.map((r) => r.current);
const countEl = countRef.current;
if (chips.some((c) => !c) || !countEl) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
RAW.forEach((_c, i) => {
const rgb = RGB[i];
const hex = "#" + toHex(rgb[0]) + toHex(rgb[1]) + toHex(rgb[2]);
chips[i]!.style.background = hex;
});
countEl.textContent = `${RAW.length}개 → ${kept.length}개`;
function apply(t: number) {
const p = (t / CYCLE_MS) % 1;
const mergeAmount = p < .5 ? p * 2 : (1 - p) * 2;
for (let i = 0; i < RAW.length; i++) {
const own = centerOf(i);
if (groupOf[i] === i) {
const pulse = 1 + .3 * mergeAmount;
const tilt = mergeAmount * 10;
chips[i]!.style.transform = `translate(${own.x.toFixed(2)}px, ${own.y.toFixed(2)}px) scale(${pulse.toFixed(3)}) rotate(${tilt.toFixed(2)}deg)`;
chips[i]!.style.opacity = "1";
} else {
const target = centerOf(groupOf[i]);
const x = own.x + (target.x - own.x) * mergeAmount;
const y = own.y + (target.y - own.y) * mergeAmount;
const scale = 1 - .95 * mergeAmount;
const opacity = 1 - .9 * mergeAmount;
chips[i]!.style.transform = `translate(${x.toFixed(2)}px, ${y.toFixed(2)}px) scale(${scale.toFixed(3)})`;
chips[i]!.style.opacity = opacity.toFixed(3);
}
}
}
if (reduce) {
apply(0);
return;
}
let start: number | null = null;
let raf = 0;
function loop(ts: number) {
if (start === null) start = ts;
const t = (ts - start) % CYCLE_MS;
apply(t);
raf = requestAnimationFrame(loop);
}
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div className={styles.stage}>
<section className={styles.panel}>
<div className={styles.grid} role="list" aria-label="최근 사용한 색상 목록">
{chipRefs.map((r, i) => (
<span key={i} className={styles.chip} ref={r} role="img" aria-label={RECENT_LABELS[i]} />
))}
</div>
<p className={styles.count} ref={countRef}>10개 → 10개</p>
</section>
</div>
);
}09 · 履歴色 — 色同士の距離を計算し近い色をまとめるリスト型カラーピッカー — 計測 累積面積 13.808% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
distとMERGE_DIST30が色同士の距離を測り近い色をまとめる
先頭から順に既に採用した色との距離をkept配列の中で調べ、MERGE_DISTより近い色が見つかった時点でその色のグループに合流させるため、RAW.length10とkept.length7を使って「10個 → 7個」という表示が一度だけ求められます。
function dist(a, b) {
return Math.sqrt(
Math.pow(a[0] - b[0], 2) + Math.pow(a[1] - b[1], 2) + Math.pow(a[2] - b[2], 2)
);
}
var RGB = RAW.map(function (c) { return hslToRgb(c.h, c.s, c.l); });
var kept = [];
var groupOf = new Array(RAW.length);
for (var i = 0; i < RAW.length; i++) {
var found = -1;
for (var j = 0; j < kept.length; j++) {
if (dist(RGB[i], RGB[kept[j]]) < MERGE_DIST) { found = kept[j]; break; }
}
if (found === -1) {
kept.push(i);
groupOf[i] = i;
} else {
groupOf[i] = found;
}
}
どこで使うか — ブランドカラーの編集画面と商品オプション画面
デザインシステムを管理する担当者がカラーピッカーを探すのは、たいてい既に決まったブランドカラーのパレットを編集画面で触れるようにしたい場面です。01のようにリング状のホイールで角度を操作させるか、05のように3つの表記を1画面で揃えて見せるかは、その編集画面を使うのがデザイナーなのか開発者なのかで変わります。
商品オプション画面を作るチームは別の課題を抱えています。09の履歴色一覧のように、直前に選んだ色をもう一度呼び出せるようにしたいものの、わずかに違う色まで全部残すと一覧が際限なく伸びてしまいます。距離の基準をどこに置いて似た色をまとめるかが、この画面の核心です。
どちらの場面でも、9つ全部を一度に組み込むよりも、必要な計算を1つか2つだけ取り出して使いたいことが多くあります。角度・明るさ・距離のうちどの計算が実際に必要かを先に見極めることが、どちらの場合も最初の一歩になります。
数値が画面を変える場所 — 角度・明るさ・距離
角度が色相を動かす場所
01iOSホイールでは、ハンドルの座標という2つの数値がMath.atan2で1つの角度に変わり、その角度がそのまま色相という1つの数値になってhslToRgbに渡されます。ハンドルの座標を直接色に書くのではなく、角度という中間値を経由している点がポイントです。
明るさが枠線とチェックアイコンの色を動かす場所
04macOSクレヨンと07スウォッチチップでは、選択中の色のRGB値という3つの数値が明るさという1つの数値に変わり、その明るさがLUM_THRESHOLDや150という閾値をまたいだ瞬間だけ枠線やチェックアイコンの色という2値の状態を切り替えます。連続した数値が離散した状態を1回だけ変える、という関係です。
距離が統合グループを動かす場所
09履歴色では、2色のRGB値という6つの数値がdistで1つの距離に変わり、その距離がMERGE_DIST30を下回るかどうかで、10個あった色が7個のグループへ統合されるかどうかが決まります。
3つのバリエーションとアクセシビリティ
紙のポスターに9色の見本を並べるのと違い、この9種は同じ見た目のまま定数を1つ変えるだけで体験の重さが変わります。9種のカラーピッカーはすべて59xbym8tで開くzipにまとめられており、vanillaフォルダには9項目それぞれのindex.htmlが、reactフォルダには同じ計算をコンポーネント化したtsxファイルが入っています。
| 名前 | 値 | 感じ方 |
|---|---|---|
| クレヨンの明るさ閾値 | 04 LUM_THRESHOLD .5 → .6 |
閾値を.5にすると中間の明るさの色の近くで枠線の色が頻繁に入れ替わって落ち着かなく見え、.6に上げると入れ替わる地点が明るい側へ移り、暗い色のグループでは白い枠線がより長く保たれます |
| 履歴色の統合距離 | 09 MERGE_DIST 10 → 30 |
統合距離を10にすると微妙に違う色まですべて別々に残って一覧が長くなり、30に上げると似た色同士がよくまとまって一覧は短くなりますが、実際には違う色が1つにまとめられてしまう危険が大きくなります |
| HSLスライダーのグラデーション段数 | 06 GRADIENT_STEPS 6 → 20 |
段数を6にするとトラックのグラデーションが階段状にかくかく見えますが描き直すコストは小さく、20に上げると色は滑らかにつながりますが色相が変わるたびに計算し直す値が増えます |
prefers-reduced-motionをどう扱うか
9項目ともwindow.matchMedia('(prefers-reduced-motion: reduce)')の結果をreduceという変数に持ち、真であればapply(0)を1回呼ぶだけで自動再生のrAFループ自体は回しません。09履歴色はこの1回のapply(0)でもRAWの10色すべてがそれぞれの本来の格子位置にとどまり、色を統合して重ねる演出だけが止まった状態になります。aria-valuenow・aria-valuetext・aria-checkedのような属性はアニメーションの有無に関わらず常に実際の状態を反映します。
コピー&ペーストで動く理由
9項目とも定数・関数・クラス名を1つのindex.htmlの中だけで完結させ、外部のライブラリやCDNは一切呼び出しません。フォルダを開いてブラウザに読み込ませるだけで、追加のビルド手順なしに同じ計算がそのまま動き出します。自分のカラーピッカーを自作する際も、名前付き定数の値だけ差し替えれば同じ計算の層をそのまま流用できます。
極端な色と連続した重複選択、どこで壊れるか
明度が0に近い極端な色で、色相の指定が意味を失うとき
03WindowsスペクトラムのhsvToRgbは明度(val)が0に近づくと、色相(hue)にどんな値を渡してもRGBはほぼ(0, 0, 0)の黒に収束します。正方形パッドの下端に近づくほど色相環のどの角度を指していても見た目の違いがなくなるということで、色相という数値が常に意味を持つとは限らない例です。
ORDER配列の始点と終点が重なり、連続して同じ色が選ばれ続けるとき
04macOSクレヨンのORDERは先頭0から末尾でもう一度0へ戻る並びのため、サイクルの終わり際と始まり際の両方で同じクレヨン0が選ばれ続ける区間が生まれます。centerOf(ORDER[idx])とcenterOf(ORDER[idx+1])が同じ地点を指す間はリングの座標も動かないので、8色分の移動の後にもう一度クレヨン0で足踏みしてから折り返す、という見え方になります。
統合された色のopacityが、0にはならないとき
09履歴色の統合演出ではopacity = 1 - .9 * mergeAmountという式でチップを薄くしますが、mergeAmountは最大でも1までしか大きくならないため、opacityの最小値は0.1で止まり完全な透明(0)には一度もなりません。「html カラーピッカー 透明度」のような検索でアルファ値そのものを扱いたい場合、この9種にはRGBAやHSLAのアルファチャンネルを操作する項目がないため、統合済みの色が完全に消えることはなく薄くなった状態で重なって見え続けます。
埋め込む前によくある質問
9つのカラーピッカーはコピペだけで動きますか
はい。9項目とも定数・関数を1つのindex.htmlの中に完結させてあり、外部のライブラリやCDNを呼び出していないため、フォルダごと開くだけで同じ計算がそのまま動きます。
html カラーピッカーに本物のスポイト機能は使われていますか
いいえ。08スポイトはEyeDropperAPIを呼ばず、あらかじめキャンバスに描いた4本の帯の上を移動するルーペの座標からgetImageDataでピクセルを読み取るだけの、自前のキャンバスに限定したシミュレーションです。
css カラーピッカー 表示 の見た目だけを変えたい場合、どこを直せばいいですか
色や大きさのような見た目はCSS側の変数・クラスだけで調整でき、apply関数の中にある色相・彩度・明度や距離の計算式には触れる必要がありません。06HSLスライダーならGRADIENT_STEPSを増減して見た目の滑らかさだけを変える、というように計算とスタイルの層が分かれています。








