컬러피커 UI 디자인 9종 — 휠부터 최근 색상까지
선택 지점의 각도가 색상값으로 다시 계산되고, 스와치 밝기 값에 따라 체크 아이콘 색이 뒤바뀌고, 최근 색상 사이 거리가 계산되어 가까운 색끼리 합쳐지는 컬러피커 UI 9종입니다. 아홉 화면 전부 속성 값 바꾸기로 끝나지 않고 각도·밝기·거리를 직접 계산해 화면을 다시 그립니다.
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
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 iOS 휠 — 선택 지점의 각도를 색조로 다시 구하는 원형 컬러피커
- 02 Material 슬라이더 — 세 손잡이 위치를 합쳐 미리보기 색을 계산하는 컬러피커
- 03 Windows 스펙트럼 — 2차원 위치를 채도·명도로 나눠 구하는 컬러피커
- 04 macOS 크레용 — 밝기 값으로 테두리 색을 정하는 격자형 컬러피커
- 05 Figma 패널 — 세 표기법을 서로 맞춰 다시 구하는 컬러피커
- 06 HSL 슬라이더 — 색조 값에 따라 트랙 그라디언트를 다시 그리는 컬러피커
- 07 스와치 칩 — 밝기 값으로 체크 아이콘 대비를 맞추는 컬러피커
- 08 스포이드 — 추출된 색의 밝기로 미리보기를 다시 구하는 컬러피커
- 09 최근 색상 — 색상 간 거리를 계산해 가까운 색을 합치는 목록형 컬러피커
데모
아홉 화면은 같은 뼈대로 돌아갑니다. requestAnimationFrame 루프가 고정된 시간값을 흘려보내고 각 화면의 apply 함수가 그 시간값을 자기 방식의 계산으로 바꿔 화면에 다시 씁니다. 사람이 색을 고르는 흐름을 시간값이 대신 걷는 자동 시연이라서, 어떤 값이 어느 계산으로 들어가 화면에 나오는지 줄 단위로 따라갈 수 있습니다. 이런 자동 시연 묶음을 한 곳에서 훑어보고 싶을 때 이 컬러피커 사이트의 격자 카드를 열어 두면 아홉 화면이 한눈에 들어옵니다.
실측 한눈에
| 항목 | 초점 | 누적면적 % | 강도 평균 | 움직인 프레임 |
|---|---|---|---|---|
| 01 iOS 휠 | 각도 → 색조 | 8.494 | 88.7 | 23 |
| 02 Material 슬라이더 | 세 값 합성 | 17.368 | 72.3 | 23 |
| 03 Windows 스펙트럼 | 2차원 → 채도·명도 | 15.937 | 38.1 | 23 |
| 04 macOS 크레용 | 휘도 → 테두리 | 14.251 | 56.6 | 23 |
| 05 Figma 패널 | 세 표기 동기 | 10.797 | 42.9 | 23 |
| 06 HSL 슬라이더 | 그라디언트 재계산 | 15.53 | 44.5 | 23 |
| 07 스와치 칩 | 밝기 → 체크 대비 | 9.408 | 116.1 | 7 |
| 08 스포이드 | 픽셀 샘플링 | 8.365 | 98.7 | 23 |
| 09 최근 색상 | 거리 → 병합 | 13.808 | 45.6 | 23 |
움직인 프레임의 분모는 23 입니다. 프레임이 24장 캡처되면 프레임 사이 변화량은 23개라서 23 이 최대치입니다. 07 스와치 칩만 7 인데, ORDER 순회가 칩 사이를 옮겨가는 구간과 선택이 한 칩에 머무는 구간을 이어 붙이기 때문입니다. 머무는 구간에는 화면 차이가 잦아들어 계측상 정지로 잡히는데, 시나리오 자체에 들어 있는 정지 구간이지 프레임이 빠진 것이 아닙니다. 누적면적이 8~9% 대인 01 휠과 08 스포이드는 대신 강도 평균이 88.7·98.7 로 높아서 움직이는 범위는 좁아도 그 지점의 변화는 또렷합니다.
컬러피커 UI 의 해부 — 아홉 화면이 계산하는 것
각도를 색조로 바꾸는 축
01 휠은 선택 지점의 방향을 Math.atan2 로 재서 0~360 도 사이의 색조로 되돌립니다. 중심에서의 거리는 반지름으로 나눠 채도가 되므로 같은 원형 안에서 색의 종류와 진하기가 한 번에 정해집니다. 06 슬라이더는 같은 색조값을 손잡이 위치와 트랙 그라디언트 양쪽에 다시 반영해 값이 바뀔 때마다 배경 스케일이 따라 옮겨집니다.
밝기가 글자색을 고르는 축
04 크레용과 07 스와치 칩은 가진 색의 RGB 에 299·587·114 가중치를 곱한 밝기 공식으로 흰 체크를 쓸지 검은 체크를 쓸지 판정합니다. 08 스포이드는 뽑아 온 색의 밝기를 글자색뿐 아니라 미리보기 테두리 두께에까지 이어 붙입니다. 색이 바뀔 때마다 대비가 같이 계산되므로 어떤 색 위에서도 표시가 묻히지 않습니다.
거리가 목록을 줄이는 축
09 최근 색상은 색마다 RGB 세 축의 유클리드 거리를 재서 기준값보다 가까운 색끼리 한 그룹으로 묶습니다. 선택 이력이 쌓여도 비슷한 색이 하나로 합쳐지므로 목록이 같은 색으로 늘어나지 않습니다. 여기까지가 아홉 화면이 공유하는 세 계산축이고, 다음 절부터는 화면마다 실제 코드를 봅니다.
화면별 코드
01iOS 휠 — 선택 지점의 각도를 색조로 다시 구하는 원형 컬러피커
휠 한 바퀴를 색조 영역으로 쓰는 원형 선택 화면입니다.
각도와 거리를 색으로 되돌리는 계산
웨이포인트 사이를 보간해 선택 지점을 옮기고 그 지점의 각도를 Math.atan2 로 재면 색조가 나옵니다. 중심에서의 거리는 RADIUS 60 으로 나눠 채도가 되고, 나온 RGB 의 밝기가 체크 글자색을 뒤집습니다.
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);
}

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
02Material 슬라이더 — 세 손잡이 위치를 합쳐 미리보기 색을 계산하는 컬러피커
세 손잡이가 색조·채도·명도를 나눠 가진 슬라이더 미리보기입니다.
구간별 값을 모아 미리보기를 갱신하는 흐름
valuesAt 이 한 주기를 세 구간으로 나눠 h/s/l 값을 만듭니다. setThumb 이 손잡이 위치와 채움 막대를 같은 비율로 움직여 세 값이 모이면 미리보기 색이 다시 계산됩니다.
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) + ')';
}

<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 슬라이더 — 세 손잡이 위치를 합쳐 미리보기 색을 계산하는 컬러피커 — 측정 누적면적 17.368% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
03Windows 스펙트럼 — 2차원 위치를 채도·명도로 나눠 구하는 컬러피커
패널의 가로·세로 위치가 채도·명도가 되는 2차원 선택 화면입니다.
6분기로 나뉘는 HSV 변환
hsvToRgb 는 색조를 60 도마다 여섯 갈래로 나눠 RGB 를 계산합니다. toHex 가 padStart 로 자릿수를 맞춰 hex 표기를 완성합니다.
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');
}

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
04macOS 크레용 — 밝기 값으로 테두리 색을 정하는 격자형 컬러피커
격자에 놓인 크레용 칸을 차례로 훑는 선택 화면입니다.
휘도 임계값으로 테두리를 고르는 판정
centerOf 보간으로 테두리가 칸 사이를 부드럽게 지나가고 frac 값이 반을 넘는 순간 선택 칸이 바뀝니다. 선택 색의 휘도를 LUM_THRESHOLD .6 과 비교해 흰 테두리와 검은 테두리를 고릅니다.
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');
}
}

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
05Figma 패널 — 세 표기법을 서로 맞춰 다시 구하는 컬러피커
hex·rgb·hsl 세 표기가 같은 색을 가리키는 패널입니다.
세 표기법을 한 번에 맞추는 동기화
PRESETS 사이를 보간해 h/s/l 을 만들면 세 표기가 한 번에 다시 쓰입니다. 어떤 표기로 읽더라도 나머지 두 표기가 같은 색을 따라갑니다.
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;
}

<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 패널 — 세 표기법을 서로 맞춰 다시 구하는 컬러피커 — 측정 누적면적 10.797% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
06HSL 슬라이더 — 색조 값에 따라 트랙 그라디언트를 다시 그리는 컬러피커
세 트랙의 배경이 색조에 맞춰 다시 그려지는 슬라이더입니다.
트랙 그라디언트를 다시 그리는 정지점
sin 파형으로 채도·명도를 움직이고 buildGradient 가 GRADIENT_STEPS 20 개 정지점으로 트랙 배경을 재계산합니다. 미리보기와 세 손잡이의 aria-valuenow 가 같은 주기로 갱신됩니다.
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)));
}

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
07스와치 칩 — 밝기 값으로 체크 아이콘 대비를 맞추는 컬러피커
스와치 칩을 순회하며 선택을 옮기는 목록형 화면입니다.
칩마다 밝기를 재는 체크 판정
ORDER 순회 중 frac 이 반을 넘으면 선택 칩이 바뀌고 그 순간 강도가 116.1 까지 치솟습니다. 칩마다 밝기 공식으로 체크 아이콘 색을 정해 대비를 유지합니다.
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);
}

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
08스포이드 — 추출된 색의 밝기로 미리보기를 다시 구하는 컬러피커
캔버스 위를 스포이드가 지나며 색을 뽑아 오는 화면입니다.
캔버스 좌표에서 픽셀을 꺼내 오는 경로
삼각파와 sin 곡선을 섞은 경로 위에서 getImageData 로 정확히 한 픽셀을 꺼냅니다. 뽑은 색의 밝기가 미리보기 테두리 두께를 MIN_BORDER 1 부터 MAX_BORDER 6 사이에서 정합니다.
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;
}

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
09최근 색상 — 색상 간 거리를 계산해 가까운 색을 합치는 목록형 컬러피커
최근에 고른 색을 모아 두고 비슷한 색끼리 합치는 목록입니다.
3차원 거리로 같은 그룹을 찾는 병합
dist 가 RGB 세 축의 유클리드 거리를 재고 MERGE_DIST 30 보다 가까우면 같은 그룹으로 묶입니다. 목록 개수 표시가 병합 전후를 실시간으로 세서 보여줍니다.
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;
}
}

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
이런 화면에서 쓴다 — 색상 선택 컴포넌트가 필요한 자리
디자인 시스템을 관리하는 사람이 컬러피커를 찾을 때는 대개 이미 정해진 브랜드 색상 팔레트를 편집 화면에 노출해야 하는 상황입니다. 01 처럼 원형 휠로 각도를 조절하게 할지, 05 처럼 세 표기법을 한 화면에 맞춰 보여줄지는 그 편집 화면을 쓰는 사람이 누구인지에 따라 갈립니다.
상품 옵션 화면을 만드는 팀은 조금 다른 문제를 풉니다. 09 의 최근 색상 목록처럼 사용자가 방금 고른 색을 다시 꺼내 쓰게 하되, 미세하게 다른 색까지 전부 남기면 목록이 끝없이 길어집니다. 거리 기준으로 비슷한 색을 합치는 판단을 어디서 내릴지가 이 화면의 핵심 질문입니다.
두 상황 모두 계산 전체를 통째로 붙이기보다 필요한 계산 한두 개만 떼어 쓰고 싶을 때가 많습니다. 화면마다 각도·밝기·거리 중 어느 계산이 실제로 필요한지 먼저 가려내면 아홉 개를 전부 붙이지 않고도 시작할 수 있습니다.
값이 화면을 바꾸는 곳 — 각도·밝기·거리
컬러피커 프로그램을 직접 짤 때 헷갈리는 지점은 색 정의가 아니라 값이 화면으로 흘러가는 길입니다. 이 아홉 화면에서 그 길은 세 가지로 압축됩니다.
각도가 색조가 되는 곳
01 휠의 Math.atan2 는 좌표 차이를 라디안 각도로 돌려주고 여기에 180 을 곱하고 파이(π)로 나눠 도 단위로 바꾼 뒤 360 으로 나머지를 잡습니다. 같은 계산을 06 슬라이더가 시간값 비율에 곱하는 방식으로 쓰면 손잡이 위치와 그라디언트 정지점이 한 값에서 함께 나옵니다. 각도 계산이 앞에 서는 순간 색 영역 표면은 값 몇 개로 정리됩니다.
밝기가 글자색을 정하는 곳
299·587·114 가중치 합은 사람 눈의 초록 감도를 흉내 낸 가중 평균으로, 04·07·08 세 화면이 같은 공식을 다른 곳에 씁니다. 04 는 휘도를 255 로 나눠 임계값과 비교하고 07 은 원값을 150 과 비교하며 08 은 테두리 두께에까지 이어 붙입니다. 같은 계산 한 줄이 세 화면에서 각자 다른 표시를 지탱하는 셈입니다.
거리가 목록을 줄이는 곳
09 의 dist 함수는 세 숫자의 제곱합에 제곱근을 씌운 가장 단순한 유클리드 거리입니다. MERGE_DIST 보다 가까운 색을 만나면 kept 배열에 새로 등록하지 않고 앞 색의 그룹 번호를 물려받으므로 목록 길이가 곧 남은 그룹 수가 됩니다. 거리 기준을 어떻게 잡느냐에 따라 같은 이력이 다른 목록이 되는 것도 이 한 줄에서 갈립니다.
표로 보는 변형 세 가지와 reduced-motion 처리
| 변형 | 바꾸는 값 | 바뀌는 느낌 |
|---|---|---|
| 크레용 밝기 임계값 | 04 LUM_THRESHOLD .5 → .6 | 임계값을 .5 로 두면 중간 밝기 색 근처에서 테두리 색이 자주 뒤바뀌어 산만해 보이고, .6 으로 올리면 뒤바뀌는 지점이 밝은 쪽으로 옮겨가 어두운 색 그룹에서는 흰 테두리가 더 오래 유지된다 |
| 최근 색상 병합 거리 | 09 MERGE_DIST 10 → 30 | 병합 거리를 10 으로 두면 미세하게 다른 색까지 전부 따로 남아 목록이 길어지고, 30 으로 올리면 비슷한 색끼리 잘 합쳐져 목록은 짧아지지만 실제로는 다른 색이 하나로 뭉뚱그려질 위험이 커진다 |
| HSL 슬라이더 그라디언트 스텝 | 06 GRADIENT_STEPS 6 → 20 | 스텝을 6 으로 두면 트랙 그라디언트가 계단처럼 뚝뚝 끊겨 보이지만 다시 그리는 비용이 적고, 20 으로 올리면 색이 매끈하게 이어지지만 색조가 바뀔 때마다 다시 계산할 값이 늘어난다 |
접근성
아홉 화면 모두 prefers-reduced-motion 설정이 켜져 있으면 requestAnimationFrame 루프를 걸지 않고 apply(0) 한 번으로 고정합니다. 정지 상태에서도 각 화면의 값 표시와 선택 상태는 살아 있어서 화면 이해에 움직임이 필수가 아닙니다. 01 휠은 aria-valuenow 와 aria-valuetext 를, 04·07 은 aria-checked 를 매 계산마다 다시 쓰니 화면을 읽는 도구도 선택값을 따라올 수 있습니다.
극단 색상과 연속 중복 선택 — 어디서 계산이 깨지는가
극단 색상 — 휠 중심에서 각도가 흔들린다
01 휠의 채도는 중심 거리를 반지름으로 나눈 값이라 중심 가까이에서는 0 에 수렴합니다. 이 지점에서 Math.atan2 에 들어가는 x·y 가 아주 작아져 부호 반전 한 번으로 각도가 크게 뛸 수 있는데, 채도가 0 이면 색조가 뛰어도 눈에 띄지 않아 계산상으로만 존재하는 흔들림이 됩니다. 웨이포인트가 중심에서 50~56 픽셀 거리를 유지하는 이유입니다.
연속 중복 선택 — 거리 0 은 언제나 병합된다
09 의 병합은 거리가 0 이면 언제나 같은 그룹으로 묶습니다. 그래서 같은 색을 되풀이해서 고르는 시나리오에서는 목록이 늘어나지 않고 한 칸의 선택 횟수만 쌓입니다. 목록이 사용 이력 그 자체를 보여주길 바란다면 병합 기준과 표시 기준을 분리해야 하는데, 이 데모는 묶는 쪽을 기본값으로 두었습니다. 전체 소스는 비밀번호 59xbym8t 로 여는 zip 에 로케일별로 들어 있으니 기준값을 바꿔 두 동작을 비교해 볼 수 있습니다.
투명도 0 — 아홉 화면은 전부 불투명만 다룬다
01 부터 09 까지 어느 화면도 알파 채널을 다루지 않습니다. 색 계산이 전부 RGB 세 축 위에서 끝나므로 rgba 표기가 들어와도 밝기 공식은 알파를 무시한 채 계산합니다. 반투명 색을 골라야 하는 화면이라면 밝기 계산 앞에 배경색과 합성하는 단계가 먼저 필요합니다.
컬러피커를 붙이기 전에 자주 묻는 질문
컬러피커 단축키로 화면을 여는 기능은 이 데모에 있나요?
이 데모 아홉 화면에는 키 입력을 받는 처리가 들어 있지 않습니다. 자동 시연이 값의 흐름을 보여주는 몫만 맡고 있기 때문입니다. 키로 열고 싶다면 화면 바깥에 keydown 이벤트를 붙여 선택 화면을 감췄다 보여주는 방식을 얹으면 되는데, 그 경우에도 색 계산 자체는 이 글의 코드 그대로 씁니다.
스포이드 화면은 왜 EyeDropper API 를 쓰지 않나요?
08 화면은 캔버스에 그린 그림 안에서 좌표를 정해 getImageData 로 한 픽셀을 읽어 오는 방식입니다. 데모 안에서 재현하는 데는 이 경로로 충분하고 셀프 컨테이너 환경 어디서든 같은 결과가 나옵니다. 화면 바깥의 임의 지점을 찍는 기능이 필요해지면 그때 브라우저 제공 스포이드 기능을 확인하는 순서가 됩니다.
컬러피커 라이브러리 대신 이 코드를 가져다 쓰는 이점은 무엇인가요?
아홉 화면의 계산이 전부 눈에 보이는 코드로 열려 있다는 점입니다. 각도·밝기·거리 각 계산이 어느 줄에서 일어나는지 추적할 수 있어서 값을 바꾸거나 그 일부만 떼어 다른 화면에 옮기기가 쉽습니다. 색을 고르는 입력 부품 자체를 바닥부터 조립하고 싶다면 컬러 피커 직접 만들기 편이, 값을 폭으로 조절하는 입력을 다루고 싶다면 css input range 슬라이더 편이 이어지는 길입니다.
더 읽을거리
- 컬러 피커 직접 만들기 CSS+JS 9종 — 입력 부품 조립 관점의 편
- css input range 슬라이더 — 관성·스냅 계산 — 폭 입력을 계산으로 다루는 편
- MDN atan2 — 각도 계산의 출처
- MDN getImageData — 픽셀 샘플링의 출처
- MDN hsl() 색 함수 — 색조·채도·명도 표기의 기준








