컬러 피커 직접 만들기 CSS+JS 9종 — 라이브러리 없이
컬러 피커는 화면에서 색을 골라 HEX 코드로 돌려주는 선택 창입니다. 이 페이지에는 그 부품 아홉 벌이 라이브러리 없이 색 공간 계산까지 직접 짠 코드로 돌아가고 있고, 코드는 통째로 복사해 쓰면 됩니다.
01 color-picker-sv-square-drag
02 color-picker-hue-wheel-angle
03 color-picker-hex-rgb-hsl-sync
04 color-picker-alpha-checkerboard
05 color-picker-recent-history
06 color-picker-harmony-generator
07 color-picker-contrast-checker
08 color-picker-gradient-stops
09 color-picker-eyedropper-canvas
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 채도·명도 사각형을 끌어 고르는 색
- 02 각도를 재서 색상을 고르는 색상환
- 03 HEX·RGB·HSL 세 칸이 서로 따라가는 입력
- 04 칸무늬 위에서 투명도를 미는 알파 슬라이더
- 05 최근 고른 색 여덟 칸이 앞으로 밀리는 기록
- 06 색상값을 돌려 만드는 보색·유사색·삼각 배색
- 07 두 색의 명암비를 재서 AA·AAA 를 붙이는 검사기
- 08 정지점을 끌어 만드는 그라디언트 막대
- 09 그림 위 픽셀을 집어 오는 스포이드
아홉 개는 색상 선택 ui 의 예쁜 순서가 아니라 색이 피커에서 일하는 순서로 놓았습니다. 색을 직접 찍어 고르는 두 축(01·02)에서 시작해, 고른 색을 표기로 주고받고(03), 투명도를 다루고(04), 그 색이 기록으로 쌓이고(05), 하나의 색이 배색으로 번지고(06), 두 색이 만나는 기준을 재고(07), 색이 그라디언트로 이어지고(08), 마지막에야 화면에서 색을 집어 옵니다(09). 아홉 벌이 같은 토큰을 나눠 쓰므로 한 페이지에 몇 개를 섞어도 톤이 어긋나지 않습니다. 고르는 대상이 색이 아니라 시각일 때는 시간 선택 UI가, 로그인한 뒤 설정 화면에 이런 선택기를 앉히는 자리는 계정 보안 설정 UI가 맡습니다.
01채도·명도 사각형을 끌어 고르는 색
컬러 피커 ui 에서 무엇이 보이나
색 고르기의 기본 축입니다. 사각형의 가로가 채도, 세로가 명도로 읽히고, 드래그하면 손잡이가 선 자리의 HSV 가 RGB 로 바뀌어 칩과 HEX 글자가 같은 순간에 따라옵니다.

<main class="stage">
<div class="pk is-demo">
<div class="pk__sv" id="svArea" role="slider" tabindex="0"
aria-label="채도와 명도 고르기"
aria-valuemin="0" aria-valuemax="100" aria-valuenow="20"
aria-valuetext="채도 20%, 명도 100%, #CCE4FF">
<span class="pk__dot" aria-hidden="true"></span>
</div>
<div class="pk__hue" id="hueBar" role="slider" tabindex="0"
aria-label="색상 고르기"
aria-valuemin="0" aria-valuemax="360" aria-valuenow="212"
aria-valuetext="색상 212도">
<span class="pk__knob" aria-hidden="true"></span>
</div>
<div class="pk__read">
<span class="pk__chip" aria-hidden="true"></span>
<span class="pk__hex" aria-hidden="true"></span>
<span class="pk__rgb" aria-hidden="true"></span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 채도·명도 사각형 — 가로 264px 이 채도 0~1, 세로 80px 이 명도 1~0 이다.
// 손잡이를 끌면 JS 가 그 자리의 HSV 를 RGB 로 바꿔 --r --g --b 에만 넣는다. 칩 색도, HEX 글자도,
// rgb() 글자도 전부 그 세 값에서 나온다 — counter-set 이 값을 계수기에 옮기고 @counter-style hx 의
// 16진 눈금이 두 자리로 찍는다. 글자를 JS 가 따로 쓰지 않으니 색과 글자가 어긋날 수가 없다.
// 미리보기 루프도 같은 세 값을 애니메이션해서 poster 의 색과 HEX 가 언제나 같은 순간을 가리킨다.
// 루프 경로는 색이 위치에 선형으로 대응하는 두 변만 쓴다 — 윗변(명도 1)과 채도 0.94 세로선.
// 그 두 변 위에서 HSV→RGB 는 1차식이라, 위치 보간값과 색 보간값이 정확히 같은 점을 가리킨다.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
@property --r { syntax: "<integer>"; inherits: true; initial-value: 204; }
@property --g { syntax: "<integer>"; inherits: true; initial-value: 228; }
@property --b { syntax: "<integer>"; inherits: true; initial-value: 255; }
// 0~15 를 16진 한 자리로 찍는 계수기 눈금. pad 2 "0" 이 0F 처럼 앞자리를 채운다
@counter-style hx {
system: numeric;
symbols: "0" "1" "2" "3" "4" "5" "6" "7" "8" "9" "A" "B" "C" "D" "E" "F";
pad: 2 "0";
}
.pk {
box-sizing: border-box;
width: 288px;
max-width: 100%;
padding: $sp-3;
border-radius: $r-card;
background: $subject-cream;
color: $subject-ink;
font-family: inherit;
--hue: 212;
}
.pk * { box-sizing: border-box; }
.pk__sv {
position: relative;
height: 80px;
border-radius: $r-sm;
cursor: crosshair;
touch-action: none;
background-color: hsl(var(--hue) 100% 50%);
background-image:
linear-gradient(to top, #000000, rgba(0, 0, 0, 0)),
linear-gradient(to right, #ffffff, rgba(255, 255, 255, 0));
}
.pk__sv:focus-visible { outline: 3px solid $color; outline-offset: 3px; }
.pk__dot {
position: absolute;
left: 0;
top: 0;
width: 14px;
height: 14px;
border-radius: 50%;
border: 2px solid #ffffff;
box-shadow: $shadow-press;
// 손잡이는 자기 중심이 고른 점 위에 놓인다 — 좌표 이동 뒤 자기 크기의 절반만큼 되민다
transform: translate(52.8px, 0) translate(-50%, -50%);
}
.pk__hue {
position: relative;
height: 12px;
margin-top: $sp-2;
border-radius: $r-pill;
cursor: pointer;
touch-action: none;
background-image: linear-gradient(to right,
#ff0000 0%, #ffff00 17%, #00ff00 33%, #00ffff 50%, #0000ff 67%, #ff00ff 83%, #ff0000 100%);
}
.pk__hue:focus-visible { outline: 3px solid $color; outline-offset: 3px; }
.pk__knob {
position: absolute;
top: 50%;
left: 58.9%;
width: 14px;
height: 14px;
border-radius: 50%;
background: #ffffff;
box-shadow: $shadow-press;
transform: translate(-50%, -50%);
}
.pk__read {
display: flex;
align-items: center;
gap: $sp-3;
margin-top: $sp-2;
}
.pk__chip {
width: 24px;
height: 24px;
border-radius: $r-xs;
background: rgb(var(--r) var(--g) var(--b));
}
// HEX·rgb() 글자 — 값은 --r --g --b 하나뿐이고 계수기가 표기만 둘로 나눈다
.pk__hex {
counter-set: cr calc(var(--r)) cg calc(var(--g)) cb calc(var(--b));
font-size: 14px;
font-weight: 700;
line-height: 1.2;
letter-spacing: .04em;
}
.pk__hex::after { content: "#" counter(cr, hx) counter(cg, hx) counter(cb, hx); }
.pk__rgb {
counter-set: cr calc(var(--r)) cg calc(var(--g)) cb calc(var(--b));
font-size: 11px;
font-weight: 500;
line-height: 1.2;
color: rgba(23, 20, 26, .62);
}
.pk__rgb::after { content: "rgb(" counter(cr) " " counter(cg) " " counter(cb) ")"; }
// ── 미리보기 루프(정적 규칙 먼저, 그 위에 덧씌움) ─────────────────
// 손잡이와 색을 같은 키프레임 퍼센트·같은 이징으로 돌린다. 두 애니메이션의 보간 비율이 같으니
// 어느 프레임에서도 손잡이가 선 자리의 색과 칩·글자의 색이 같은 값이다.
.pk.is-demo .pk__dot {
animation-name: pkDot;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.pk.is-demo {
animation-name: pkColor;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
// 채도 0.20 → 0.94 (윗변) → 명도 1 → 0.55 (세로선) → 같은 길을 되짚어 돌아온다. 90% 에 제자리
@keyframes pkDot {
0% { transform: translate(52.8px, 0) translate(-50%, -50%); }
30%, 40% { transform: translate(248.2px, 0) translate(-50%, -50%); }
60%, 70% { transform: translate(248.2px, 36px) translate(-50%, -50%); }
80% { transform: translate(248.2px, 0) translate(-50%, -50%); }
90%, 100% { transform: translate(52.8px, 0) translate(-50%, -50%); }
}
// 색상 212도의 순색은 rgb(0 119 255) 다. 윗변에서는 흰색과의 1차 혼합, 세로선에서는 그 값의 배수다
@keyframes pkColor {
0% { --r: 204; --g: 228; --b: 255; }
30%, 40% { --r: 15; --g: 127; --b: 255; }
60%, 70% { --r: 8; --g: 70; --b: 140; }
80% { --r: 15; --g: 127; --b: 255; }
90%, 100% { --r: 204; --g: 228; --b: 255; }
}
@media (prefers-reduced-motion: reduce) {
.pk.is-demo,
.pk.is-demo .pk__dot {
animation: none !important;
}
}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);
}
@property --r {
syntax: "<integer>";
inherits: true;
initial-value: 204;
}
@property --g {
syntax: "<integer>";
inherits: true;
initial-value: 228;
}
@property --b {
syntax: "<integer>";
inherits: true;
initial-value: 255;
}
@counter-style hx {
system: numeric;
symbols: "0" "1" "2" "3" "4" "5" "6" "7" "8" "9" "A" "B" "C" "D" "E" "F";
pad: 2 "0";
}
.pk {
box-sizing: border-box;
width: 288px;
max-width: 100%;
padding: 12px;
border-radius: 16px;
background: #fff7e6;
color: #17141a;
font-family: inherit;
--hue: 212;
}
.pk * {
box-sizing: border-box;
}
.pk__sv {
position: relative;
height: 80px;
border-radius: 8px;
cursor: crosshair;
touch-action: none;
background-color: hsl(var(--hue), 100%, 50%);
background-image: linear-gradient(to top, #000000, rgba(0, 0, 0, 0)), linear-gradient(to right, #ffffff, rgba(255, 255, 255, 0));
}
.pk__sv:focus-visible {
outline: 3px solid #2f6df6;
outline-offset: 3px;
}
.pk__dot {
position: absolute;
left: 0;
top: 0;
width: 14px;
height: 14px;
border-radius: 50%;
border: 2px solid #ffffff;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transform: translate(52.8px, 0) translate(-50%, -50%);
}
.pk__hue {
position: relative;
height: 12px;
margin-top: 8px;
border-radius: 999px;
cursor: pointer;
touch-action: none;
background-image: linear-gradient(to right, #ff0000 0%, #ffff00 17%, #00ff00 33%, #00ffff 50%, #0000ff 67%, #ff00ff 83%, #ff0000 100%);
}
.pk__hue:focus-visible {
outline: 3px solid #2f6df6;
outline-offset: 3px;
}
.pk__knob {
position: absolute;
top: 50%;
left: 58.9%;
width: 14px;
height: 14px;
border-radius: 50%;
background: #ffffff;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transform: translate(-50%, -50%);
}
.pk__read {
display: flex;
align-items: center;
gap: 12px;
margin-top: 8px;
}
.pk__chip {
width: 24px;
height: 24px;
border-radius: 4px;
background: rgb(var(--r), var(--g), var(--b));
}
.pk__hex {
counter-set: cr calc(var(--r)) cg calc(var(--g)) cb calc(var(--b));
font-size: 14px;
font-weight: 700;
line-height: 1.2;
letter-spacing: 0.04em;
}
.pk__hex::after {
content: "#" counter(cr, hx) counter(cg, hx) counter(cb, hx);
}
.pk__rgb {
counter-set: cr calc(var(--r)) cg calc(var(--g)) cb calc(var(--b));
font-size: 11px;
font-weight: 500;
line-height: 1.2;
color: rgba(23, 20, 26, 0.62);
}
.pk__rgb::after {
content: "rgb(" counter(cr) " " counter(cg) " " counter(cb) ")";
}
.pk.is-demo .pk__dot {
animation-name: pkDot;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.pk.is-demo {
animation-name: pkColor;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes pkDot {
0% {
transform: translate(52.8px, 0) translate(-50%, -50%);
}
30%, 40% {
transform: translate(248.2px, 0) translate(-50%, -50%);
}
60%, 70% {
transform: translate(248.2px, 36px) translate(-50%, -50%);
}
80% {
transform: translate(248.2px, 0) translate(-50%, -50%);
}
90%, 100% {
transform: translate(52.8px, 0) translate(-50%, -50%);
}
}
@keyframes pkColor {
0% {
--r: 204;
--g: 228;
--b: 255;
}
30%, 40% {
--r: 15;
--g: 127;
--b: 255;
}
60%, 70% {
--r: 8;
--g: 70;
--b: 140;
}
80% {
--r: 15;
--g: 127;
--b: 255;
}
90%, 100% {
--r: 204;
--g: 228;
--b: 255;
}
}
@media (prefers-reduced-motion: reduce) {
.pk.is-demo,
.pk.is-demo .pk__dot {
animation: none !important;
}
}(function () {
var root = document.querySelector('.pk');
var svArea = document.getElementById('svArea');
var hueBar = document.getElementById('hueBar');
var dot = root.querySelector('.pk__dot');
var knob = root.querySelector('.pk__knob');
var svFormat = '채도 %s%, 명도 %v%, %x';
var hueFormat = '색상 %h도';
var hue = 212, sat = 0.2, val = 1;
// HSV(0~360, 0~1, 0~1) → RGB(0~255). 육각형 여섯 구간을 표로 펴서 분기 없이 고른다
function hsvToRgb(h, s, v) {
var c = v * s, x = c * (1 - Math.abs(((h / 60) % 2) - 1)), m = v - c;
var face = [[c, x, 0], [x, c, 0], [0, c, x], [0, x, c], [x, 0, c], [c, 0, x]][Math.floor(h / 60) % 6];
return face.map(function (n) { return Math.round((n + m) * 255); });
}
function toHex(rgb) {
return '#' + rgb.map(function (n) { return n.toString(16).padStart(2, '0'); }).join('').toUpperCase();
}
// 글자는 JS 가 쓰지 않는다 — --r --g --b 세 개만 넘기면 counter-set 이 HEX·RGB 를 찍는다
function applyColor() {
var rgb = hsvToRgb(hue, sat, val);
root.style.setProperty('--r', rgb[0]);
root.style.setProperty('--g', rgb[1]);
root.style.setProperty('--b', rgb[2]);
svArea.style.setProperty('--hue', hue);
dot.style.transform = 'translate(' + (sat * 264).toFixed(1) + 'px, ' + ((1 - val) * 80).toFixed(1) + 'px) translate(-50%, -50%)';
knob.style.left = (hue / 360 * 100).toFixed(1) + '%';
svArea.setAttribute('aria-valuenow', Math.round(sat * 100));
svArea.setAttribute('aria-valuetext', svFormat.replace('%s', Math.round(sat * 100)).replace('%v', Math.round(val * 100)).replace('%x', toHex(rgb)));
hueBar.setAttribute('aria-valuenow', Math.round(hue));
hueBar.setAttribute('aria-valuetext', hueFormat.replace('%h', Math.round(hue)));
}
function pickAt(e) {
var box = svArea.getBoundingClientRect();
sat = Math.min(1, Math.max(0, (e.clientX - box.left) / box.width));
val = 1 - Math.min(1, Math.max(0, (e.clientY - box.top) / box.height));
applyColor();
}
function pickHue(e) {
var box = hueBar.getBoundingClientRect();
hue = Math.min(360, Math.max(0, (e.clientX - box.left) / box.width * 360));
applyColor();
}
function bindDrag(el, handler) {
el.addEventListener('pointerdown', function (e) {
root.classList.remove('is-demo');
el.setPointerCapture(e.pointerId); // 칸 밖으로 끌어도 이 요소가 계속 포인터를 받는다
handler(e);
});
el.addEventListener('pointermove', function (e) {
if (el.hasPointerCapture(e.pointerId)) { handler(e); }
});
}
bindDrag(svArea, pickAt);
bindDrag(hueBar, pickHue);
svArea.addEventListener('keydown', function (e) {
var step = e.shiftKey ? 0.1 : 0.02;
if (e.key === 'ArrowRight') { sat = Math.min(1, sat + step); }
else if (e.key === 'ArrowLeft') { sat = Math.max(0, sat - step); }
else if (e.key === 'ArrowUp') { val = Math.min(1, val + step); }
else if (e.key === 'ArrowDown') { val = Math.max(0, val - step); }
else { return; }
e.preventDefault();
root.classList.remove('is-demo');
applyColor();
});
hueBar.addEventListener('keydown', function (e) {
var step = e.shiftKey ? 15 : 3;
if (e.key === 'ArrowRight') { hue = Math.min(360, hue + step); }
else if (e.key === 'ArrowLeft') { hue = Math.max(0, hue - step); }
else { return; }
e.preventDefault();
root.classList.remove('is-demo');
applyColor();
});
applyColor();
})();// SvSquareDrag — vanilla 판(run/286/demo/01-color-picker-sv-square-drag) 의 React 포트.
// 변수 3개(duration·easing·color)는 props 로 받아 CSS 변수(var(--duration) 등)로 들어온다.
// 상태는 hue·sat·val 세 숫자뿐이다. RGB 는 그때그때 계산해 --r --g --b 로만 넘기고,
// HEX·rgb() 글자는 module.scss 의 counter-set + @counter-style 이 찍는다 — JS 가 글자를 쓰지 않는다.
import { useRef, useState } from "react";
import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react";
import styles from "./SvSquareDrag.module.scss";
type KeyEvt = { key: string; shiftKey: boolean; preventDefault(): void };
export type SvSquareDragProps = {
svLabel?: string;
hueLabel?: string;
/** "채도 %s%, 명도 %v%, %x" 처럼 자리표시자 셋을 가진 틀 */
svFormat?: string;
/** "색상 %h도" 처럼 자리표시자 하나를 가진 틀 */
hueFormat?: string;
/** 격자 미리보기용 2초 자동 루프. 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
"--hue"?: number;
"--r"?: number;
"--g"?: number;
"--b"?: number;
};
const SV_WIDTH = 264;
const SV_HEIGHT = 80;
// HSV(0~360, 0~1, 0~1) → RGB(0~255). 육각형 여섯 구간을 표로 펴서 분기 없이 고른다
function hsvToRgb(h: number, s: number, v: number): number[] {
const c = v * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m = v - c;
const faces: number[][] = [[c, x, 0], [x, c, 0], [0, c, x], [0, x, c], [x, 0, c], [c, 0, x]];
return faces[Math.floor(h / 60) % 6].map((n) => Math.round((n + m) * 255));
}
function toHex(rgb: number[]): string {
return "#" + rgb.map((n) => n.toString(16).padStart(2, "0")).join("").toUpperCase();
}
function clamp01(n: number): number {
return Math.min(1, Math.max(0, n));
}
export default function SvSquareDrag({
svLabel = "채도와 명도 고르기",
hueLabel = "색상 고르기",
svFormat = "채도 %s%, 명도 %v%, %x",
hueFormat = "색상 %h도",
demo = true,
duration = 2000,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#2f6df6",
}: SvSquareDragProps) {
const [live, setLive] = useState(demo);
const [hue, setHue] = useState(212);
const [sat, setSat] = useState(0.2);
const [val, setVal] = useState(1);
const svRef = useRef<HTMLDivElement>(null);
const hueRef = useRef<HTMLDivElement>(null);
const rgb = hsvToRgb(hue, sat, val);
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
"--hue": hue,
"--r": rgb[0],
"--g": rgb[1],
"--b": rgb[2],
};
function applySquare(e: ReactPointerEvent<HTMLDivElement>) {
const node = svRef.current;
if (!node) return;
const box = node.getBoundingClientRect();
setSat(clamp01((e.clientX - box.left) / box.width));
setVal(1 - clamp01((e.clientY - box.top) / box.height));
}
function applyHue(e: ReactPointerEvent<HTMLDivElement>) {
const node = hueRef.current;
if (!node) return;
const box = node.getBoundingClientRect();
setHue(clamp01((e.clientX - box.left) / box.width) * 360);
}
function startSquare(e: ReactPointerEvent<HTMLDivElement>) {
setLive(false);
// 칸 밖으로 끌어도 이 요소가 계속 포인터를 받는다
e.currentTarget.setPointerCapture(e.pointerId);
applySquare(e);
}
function startHue(e: ReactPointerEvent<HTMLDivElement>) {
setLive(false);
e.currentTarget.setPointerCapture(e.pointerId);
applyHue(e);
}
function dragSquare(e: ReactPointerEvent<HTMLDivElement>) {
if (e.currentTarget.hasPointerCapture(e.pointerId)) applySquare(e);
}
function dragHue(e: ReactPointerEvent<HTMLDivElement>) {
if (e.currentTarget.hasPointerCapture(e.pointerId)) applyHue(e);
}
function keySquare(e: KeyEvt) {
const step = e.shiftKey ? 0.1 : 0.02;
if (e.key === "ArrowRight") setSat(clamp01(sat + step));
else if (e.key === "ArrowLeft") setSat(clamp01(sat - step));
else if (e.key === "ArrowUp") setVal(clamp01(val + step));
else if (e.key === "ArrowDown") setVal(clamp01(val - step));
else return;
e.preventDefault();
setLive(false);
}
function keyHue(e: KeyEvt) {
const step = e.shiftKey ? 15 : 3;
if (e.key === "ArrowRight") setHue(Math.min(360, hue + step));
else if (e.key === "ArrowLeft") setHue(Math.max(0, hue - step));
else return;
e.preventDefault();
setLive(false);
}
const svText = svFormat
.replace("%s", String(Math.round(sat * 100)))
.replace("%v", String(Math.round(val * 100)))
.replace("%x", toHex(rgb));
const hueText = hueFormat.replace("%h", String(Math.round(hue)));
const dotStyle: CSSProperties = {
transform: `translate(${(sat * SV_WIDTH).toFixed(1)}px, ${((1 - val) * SV_HEIGHT).toFixed(1)}px) translate(-50%, -50%)`,
};
return (
<div className={[styles.pk, live ? styles.isDemo : ""].filter(Boolean).join(" ")} style={vars}>
<div
ref={svRef}
className={styles.sv}
role="slider"
tabIndex={0}
aria-label={svLabel}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(sat * 100)}
aria-valuetext={svText}
onPointerDown={startSquare}
onPointerMove={dragSquare}
onKeyDown={keySquare}
>
<span className={styles.dot} style={dotStyle} aria-hidden="true" />
</div>
<div
ref={hueRef}
className={styles.hue}
role="slider"
tabIndex={0}
aria-label={hueLabel}
aria-valuemin={0}
aria-valuemax={360}
aria-valuenow={Math.round(hue)}
aria-valuetext={hueText}
onPointerDown={startHue}
onPointerMove={dragHue}
onKeyDown={keyHue}
>
<span className={styles.knob} style={{ left: `${((hue / 360) * 100).toFixed(1)}%` }} aria-hidden="true" />
</div>
<div className={styles.read}>
<span className={styles.chip} aria-hidden="true" />
<span className={styles.hex} aria-hidden="true" />
<span className={styles.rgb} aria-hidden="true" />
</div>
</div>
);
}01 · 채도·명도 사각형을 끌어 고르는 색 — 측정 누적면적 2.738% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
이 데모의 누적 변화 면적은 화면의 2.738% 로 아홉 벌 중 가장 조용하고, 디자인 도구의 테마 편집기에 그대로 넣을 수 있습니다.
// 등록 속성 세 개가 색의 유일한 출처다. 칩 배경도 HEX 글자도 전부 여기서 나온다
@property --r { syntax: "<integer>"; inherits: true; initial-value: 204; }
@property --g { syntax: "<integer>"; inherits: true; initial-value: 228; }
@property --b { syntax: "<integer>"; inherits: true; initial-value: 255; }
@counter-style hx {
system: numeric;
symbols: "0" "1" "2" "3" "4" "5" "6" "7" "8" "9" "A" "B" "C" "D" "E" "F";
pad: 2 "0"; // 0F 처럼 앞자리를 채운다
}
.pk__hex { counter-set: cr calc(var(--r)) cg calc(var(--g)) cb calc(var(--b)); }
.pk__hex::after { content: "#" counter(cr, hx) counter(cg, hx) counter(cb, hx); }
// HSV(0~360, 0~1, 0~1) → RGB(0~255). 여섯 구간을 표로 펴 분기 없이 고른다
function hsvToRgb(h, s, v) {
var c = v * s, x = c * (1 - Math.abs(((h / 60) % 2) - 1)), m = v - c;
var face = [[c, x, 0], [x, c, 0], [0, c, x], [0, x, c], [x, 0, c], [c, 0, x]][Math.floor(h / 60) % 6];
return face.map(function (n) { return Math.round((n + m) * 255); });
}
// 칸 밖으로 끌어도 이 요소가 계속 포인터를 받는다 — 드래그가 끊기지 않는 핵심 한 줄
el.setPointerCapture(e.pointerId);
02각도를 재서 색상을 고르는 색상환
화면에서 일어나는 일
색상(hue)만 고를 때는 사각형보다 원이 빠릅니다. 원 안을 누른 자리의 각도가 곧 0~360 색상값이 되고, 손잡이는 링에서 자기 색 위에 섭니다.

<main class="stage">
<div class="hw is-demo">
<div class="hw__ring" id="hueWheel" role="slider" tabindex="0"
aria-label="색상환에서 각도로 고르기"
aria-valuemin="0" aria-valuemax="360" aria-valuenow="212"
aria-valuetext="색상환 각도 212도">
<span class="hw__core" aria-hidden="true"></span>
<span class="hw__arm" aria-hidden="true"></span>
</div>
<p class="hw__read" aria-hidden="true">
<span class="hw__cap">색상환 각도</span><span class="hw__val"></span>
</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 색상환 — 원 위를 누른 자리의 각도가 곧 색상값(0~360)이다.
// 움직이는 값은 --h 하나뿐이고, 손잡이 각도·가운데 원 색·아래 숫자가 전부 그 한 값에서 나온다.
// 그래서 어느 프레임을 잘라도 손잡이가 선 자리의 색과 원 색과 숫자가 같은 값을 가리킨다.
// conic-gradient 의 from 90deg 는 빨강(색상 0)을 3시에 놓는다 — Math.atan2 의 0 과 같은 자리라
// JS 는 잰 각도를 그대로 색상값으로 쓰고, 손잡이만 +90 으로 그 기준에 맞춘다.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-paper);
// 각도. <integer> 로 등록해 counter-set 이 그대로 받아 쓴다 — 한 값이 각도·색·숫자를 함께 움직인다
@property --h { syntax: "<integer>"; inherits: true; initial-value: 212; }
.hw {
box-sizing: border-box;
display: grid;
justify-items: center;
width: 288px;
max-width: 100%;
padding: $sp-3;
border-radius: $r-card;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
}
.hw * { box-sizing: border-box; }
.hw__ring {
position: relative;
width: 120px;
height: 120px;
border-radius: 50%;
cursor: pointer;
touch-action: none;
// 색상환 한 바퀴. from 90deg 라 빨강이 3시에서 시작해 시계방향으로 돈다
background-image: conic-gradient(from 90deg,
#ff0000, #ffff00, #00ff00, #00ffff, #0000ff, #ff00ff, #ff0000);
}
.hw__ring:focus-visible { outline: 3px solid $color; outline-offset: 3px; }
.hw__core {
position: absolute;
left: 50%;
top: 50%;
width: 84px;
height: 84px;
border-radius: 50%;
// 자기 중심을 링 중심에 맞춘다. 가운데를 덮어 링 두께가 18px 로 남는다
transform: translate(-50%, -50%);
background: hsl(var(--h) 92% 52%);
box-shadow: 0 0 0 4px $subject-ink; // 카드 바탕색 테두리가 링과 가운데 원을 갈라 놓는다
}
.hw__arm {
position: absolute;
inset: 0;
// 손잡이는 링에 붙어 도는 요소다 — 회전 중심이 자기 중심이 아니라 링 중심이라 링 크기 레이어째 돌린다.
// +90 은 conic 의 from 90deg 보정이다. 색상 0 이 3시에 있으니 손잡이도 3시에서 출발해야 색 위에 선다
transform: rotate(calc((var(--h) + 90) * 1deg));
}
.hw__arm::after {
content: "";
position: absolute;
left: 50%;
top: 50%;
width: 18px;
height: 18px;
border-radius: 50%;
background: $subject-cream;
box-shadow: $shadow-press;
// 자기 크기의 절반만큼 되민 뒤 링 두께 한가운데(반지름 51px)로 올린다
transform: translate(-50%, -50%) translateY(-51px);
}
.hw__read {
display: flex;
align-items: baseline;
gap: $sp-2;
margin: $sp-1 0 0;
}
.hw__cap {
font-size: 11px;
font-weight: 500;
line-height: 1.2;
color: rgba(255, 247, 230, .66);
}
// 숫자는 JS 가 쓰지 않는다 — counter-set 이 --h 를 계수기로 옮기고 ::after 가 찍는다
.hw__val {
counter-set: ch calc(var(--h));
font-size: 13px;
font-weight: 700;
line-height: 1.2;
letter-spacing: .04em;
}
.hw__val::after { content: counter(ch) "°"; }
// ── 미리보기 루프(정적 규칙 먼저, 그 위에 덧씌움) ─────────────────
.hw.is-demo {
animation-name: hwSpin;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
// 212도(파랑) → 344도(분홍) → 같은 길을 되짚어 212도. 90% 에 제자리로 돌아온다
// (24프레임의 마지막 장이 95.8% 지점이라 그 뒤에 두면 복귀가 영영 안 찍힌다)
@keyframes hwSpin {
0% { --h: 212; }
45% { --h: 344; }
90%, 100% { --h: 212; }
}
@media (prefers-reduced-motion: reduce) {
.hw.is-demo { animation: none !important; }
}@charset "UTF-8";
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);
}
@property --h {
syntax: "<integer>";
inherits: true;
initial-value: 212;
}
.hw {
box-sizing: border-box;
display: grid;
justify-items: center;
width: 288px;
max-width: 100%;
padding: 12px;
border-radius: 16px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
}
.hw * {
box-sizing: border-box;
}
.hw__ring {
position: relative;
width: 120px;
height: 120px;
border-radius: 50%;
cursor: pointer;
touch-action: none;
background-image: conic-gradient(from 90deg, #ff0000, #ffff00, #00ff00, #00ffff, #0000ff, #ff00ff, #ff0000);
}
.hw__ring:focus-visible {
outline: 3px solid #2f6df6;
outline-offset: 3px;
}
.hw__core {
position: absolute;
left: 50%;
top: 50%;
width: 84px;
height: 84px;
border-radius: 50%;
transform: translate(-50%, -50%);
background: hsl(var(--h), 92%, 52%);
box-shadow: 0 0 0 4px #17141a;
}
.hw__arm {
position: absolute;
inset: 0;
transform: rotate(calc((var(--h) + 90) * 1deg));
}
.hw__arm::after {
content: "";
position: absolute;
left: 50%;
top: 50%;
width: 18px;
height: 18px;
border-radius: 50%;
background: #fff7e6;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transform: translate(-50%, -50%) translateY(-51px);
}
.hw__read {
display: flex;
align-items: baseline;
gap: 8px;
margin: 4px 0 0;
}
.hw__cap {
font-size: 11px;
font-weight: 500;
line-height: 1.2;
color: rgba(255, 247, 230, 0.66);
}
.hw__val {
counter-set: ch calc(var(--h));
font-size: 13px;
font-weight: 700;
line-height: 1.2;
letter-spacing: 0.04em;
}
.hw__val::after {
content: counter(ch) "°";
}
.hw.is-demo {
animation-name: hwSpin;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes hwSpin {
0% {
--h: 212;
}
45% {
--h: 344;
}
90%, 100% {
--h: 212;
}
}
@media (prefers-reduced-motion: reduce) {
.hw.is-demo {
animation: none !important;
}
}(function () {
var root = document.querySelector('.hw');
var wheel = document.getElementById('hueWheel');
var hueFormat = '색상환 각도 %h도';
var hue = 212;
// 값은 --h 하나뿐이다. 손잡이 각도도, 가운데 원 색도, 아래 숫자도 전부 이 한 값에서 나온다 —
// 글자는 CSS 계수기가 찍으므로 JS 가 숫자를 따로 쓸 일이 없고, 색과 글자가 어긋날 수가 없다
function applyHue() {
root.style.setProperty('--h', hue);
wheel.setAttribute('aria-valuenow', hue);
wheel.setAttribute('aria-valuetext', hueFormat.replace('%h', hue));
}
// 중심에서 포인터까지의 각도를 잰다. Math.atan2 는 3시 방향이 0 이고 시계방향으로 커지는데,
// conic-gradient(from 90deg) 도 빨강(색상 0)을 3시에 놓는다 — 두 기준이 같아 보정 없이 그대로 색상값이다
function pickAngle(e) {
var box = wheel.getBoundingClientRect();
var deg = Math.atan2(e.clientY - box.top - box.height / 2, e.clientX - box.left - box.width / 2) * 180 / Math.PI;
hue = Math.round((deg + 360) % 360) % 360; // 등록 속성 --h 는 정수만 받는다
applyHue();
}
wheel.addEventListener('pointerdown', function (e) {
root.classList.remove('is-demo');
wheel.setPointerCapture(e.pointerId); // 원 밖으로 끌어도 이 요소가 각도를 계속 받는다
pickAngle(e);
});
wheel.addEventListener('pointermove', function (e) {
if (wheel.hasPointerCapture(e.pointerId)) { pickAngle(e); }
});
wheel.addEventListener('keydown', function (e) {
var step = e.shiftKey ? 15 : 3;
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') { hue = (hue + step) % 360; }
else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') { hue = (hue - step + 360) % 360; }
else { return; }
e.preventDefault();
root.classList.remove('is-demo');
applyHue();
});
applyHue();
})();// HueWheelAngle — vanilla 판(run/286/demo/02-color-picker-hue-wheel-angle) 의 React 포트.
// 변수 3개(duration·easing·color)는 props 로 받아 CSS 변수(var(--duration) 등)로 들어온다.
// 상태는 hue 숫자 하나뿐이다. 손잡이 각도도, 가운데 원 색도, 아래 숫자도 전부 --h 한 값에서 나오고
// 숫자는 module.scss 의 counter-set 이 찍는다 — JS 가 글자를 쓰지 않는다.
import { useState } from "react";
import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react";
import styles from "./HueWheelAngle.module.scss";
type KeyEvt = { key: string; shiftKey: boolean; preventDefault(): void };
export type HueWheelAngleProps = {
wheelLabel?: string;
capLabel?: string;
/** "색상환 각도 %h도" 처럼 자리표시자 하나를 가진 틀 */
hueFormat?: string;
/** 격자 미리보기용 2초 자동 루프. 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
"--h"?: number;
};
export default function HueWheelAngle({
wheelLabel = "색상환에서 각도로 고르기",
capLabel = "색상환 각도",
hueFormat = "색상환 각도 %h도",
demo = true,
duration = 2000,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#2f6df6",
}: HueWheelAngleProps) {
const [live, setLive] = useState(demo);
const [hue, setHue] = useState(212);
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
"--h": hue,
};
// 중심에서 포인터까지의 각도를 잰다. Math.atan2 는 3시 방향이 0 이고 시계방향으로 커지는데,
// conic-gradient(from 90deg) 도 빨강(색상 0)을 3시에 놓는다 — 기준이 같아 보정 없이 그대로 색상값이다
function angleAt(e: ReactPointerEvent<HTMLDivElement>): number {
const box = e.currentTarget.getBoundingClientRect();
const deg =
(Math.atan2(e.clientY - box.top - box.height / 2, e.clientX - box.left - box.width / 2) * 180) / Math.PI;
return Math.round((deg + 360) % 360) % 360;
}
function start(e: ReactPointerEvent<HTMLDivElement>) {
setLive(false);
e.currentTarget.setPointerCapture(e.pointerId); // 원 밖으로 끌어도 각도를 계속 받는다
setHue(angleAt(e));
}
function drag(e: ReactPointerEvent<HTMLDivElement>) {
if (e.currentTarget.hasPointerCapture(e.pointerId)) setHue(angleAt(e));
}
function press(e: KeyEvt) {
const step = e.shiftKey ? 15 : 3;
if (e.key === "ArrowRight" || e.key === "ArrowUp") setHue((hue + step) % 360);
else if (e.key === "ArrowLeft" || e.key === "ArrowDown") setHue((hue - step + 360) % 360);
else return;
e.preventDefault();
setLive(false);
}
const hueText = hueFormat.replace("%h", String(hue));
return (
<div className={[styles.hw, live ? styles.isDemo : ""].filter(Boolean).join(" ")} style={vars}>
<div
className={styles.ring}
role="slider"
tabIndex={0}
aria-label={wheelLabel}
aria-valuemin={0}
aria-valuemax={360}
aria-valuenow={hue}
aria-valuetext={hueText}
onPointerDown={start}
onPointerMove={drag}
onKeyDown={press}
>
<span className={styles.core} aria-hidden="true" />
<span className={styles.arm} aria-hidden="true" />
</div>
<p className={styles.read} aria-hidden="true">
<span className={styles.cap}>{capLabel}</span>
<span className={styles.val} />
</p>
</div>
);
}02 · 각도를 재서 색상을 고르는 색상환 — 측정 누적면적 5.619% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
움직이는 값은 --h 하나뿐입니다.
// 중심에서 포인터까지의 각도를 잰다. Math.atan2 는 3시 방향이 0 이고 시계방향으로 커지는데,
// conic-gradient(from 90deg) 도 빨강(색상 0)을 3시에 놓는다 — 두 기준이 같아 보정 없이 그대로 색상값이다
var deg = Math.atan2(e.clientY - box.top - box.height / 2,
e.clientX - box.left - box.width / 2) * 180 / Math.PI;
hue = Math.round((deg + 360) % 360) % 360;
// 손잡이는 링에 붙어 도는 요소다 — 회전 중심이 자기 중심이 아니라 링 중심이라 링 크기 레이어째 돌린다.
// +90 은 conic 의 from 90deg 보정이다. 색상 0 이 3시에 있으니 손잡이도 3시에서 출발해야 색 위에 선다
.hw__arm { transform: rotate(calc((var(--h) + 90) * 1deg)); }
.hw__arm::after {
transform: translate(-50%, -50%) translateY(-51px); // 링 두께 한가운데(반지름 51px)
}
.hw__core { background: hsl(var(--h) 92% 52%); }
03HEX·RGB·HSL 세 칸이 서로 따라가는 입력
어떤 움직임인가
같은 색을 표기 셋으로 주고받는 입력입니다. 한 칸에 값을 넣으면 나머지 두 칸이 같은 색으로 다시 계산되고, 형식이 어긋난 칸만 aria-invalid 로 붉게 남습니다.

<main class="stage">
<div class="sy is-demo">
<div class="sy__band" aria-hidden="true"></div>
<div class="sy__row" data-kind="hex">
<span class="sy__label" aria-hidden="true">HEX</span>
<span class="sy__field">
<input class="sy__input" type="text" spellcheck="false" autocomplete="off"
aria-label="HEX 값 입력" aria-invalid="false" value="#0D78F2">
<span class="sy__ghost sy__ghost--hex" aria-hidden="true"></span>
<span class="sy__bad" aria-hidden="true">형식 오류</span>
</span>
</div>
<div class="sy__row" data-kind="rgb">
<span class="sy__label" aria-hidden="true">RGB</span>
<span class="sy__field">
<input class="sy__input" type="text" spellcheck="false" autocomplete="off"
aria-label="RGB 값 입력" aria-invalid="false" value="rgb(13, 120, 242)">
<span class="sy__ghost sy__ghost--rgb" aria-hidden="true"></span>
<span class="sy__bad" aria-hidden="true">형식 오류</span>
</span>
</div>
<div class="sy__row" data-kind="hsl">
<span class="sy__label" aria-hidden="true">HSL</span>
<span class="sy__field">
<input class="sy__input" type="text" spellcheck="false" autocomplete="off"
aria-label="HSL 값 입력" aria-invalid="false" value="hsl(212, 90%, 50%)">
<span class="sy__ghost sy__ghost--hsl" aria-hidden="true"></span>
<span class="sy__bad" aria-hidden="true">형식 오류</span>
</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// HEX·RGB·HSL 세 칸 동기 — 한 칸에 값을 넣으면 나머지 두 칸이 같은 색으로 다시 계산된다.
// 색을 담는 값은 등록 속성 여섯 개(--r --g --b --hh --ss --ll)뿐이고, 띠 색도 미리보기 글자도
// 전부 그 여섯에서 나온다. 글자는 counter-set 이 계수기로 옮기고 @counter-style hx 가 16진으로 찍는다.
// 미리보기 루프는 색상 212·채도 90% 를 고정하고 명도만 움직인다 — 명도 l ≤ 0.5 구간에서
// hsl(212, 90%, l) 의 RGB 는 (0.1l, 0.94l, 1.9l) × 255 라 l 에 완전히 비례한다.
// 그래서 RGB 와 HSL 을 같은 키프레임에 같은 비율(linear)로 보간해도 두 표기가 같은 색을 가리킨다.
$duration: $dur-loop;
$easing: linear;
$color: $subject-blue;
@include stage($stage-yellow);
@property --r { syntax: "<integer>"; inherits: true; initial-value: 13; }
@property --g { syntax: "<integer>"; inherits: true; initial-value: 120; }
@property --b { syntax: "<integer>"; inherits: true; initial-value: 242; }
@property --hh { syntax: "<integer>"; inherits: true; initial-value: 212; }
@property --ss { syntax: "<integer>"; inherits: true; initial-value: 90; }
@property --ll { syntax: "<integer>"; inherits: true; initial-value: 50; }
// 0~15 를 16진 한 자리로 찍는 계수기 눈금. pad 2 "0" 이 0F 처럼 앞자리를 채운다
@counter-style hx {
system: numeric;
symbols: "0" "1" "2" "3" "4" "5" "6" "7" "8" "9" "A" "B" "C" "D" "E" "F";
pad: 2 "0";
}
.sy {
box-sizing: border-box;
width: 288px;
max-width: 100%;
padding: $sp-3;
border-radius: $r-card;
background: $subject-cream;
color: $subject-ink;
font-family: inherit;
}
.sy * { box-sizing: border-box; }
.sy__band {
height: 32px;
border-radius: $r-sm;
background: rgb(var(--r) var(--g) var(--b));
}
.sy__row {
display: grid;
grid-template-columns: 36px 1fr;
align-items: center;
gap: $sp-2;
margin-top: $sp-1;
}
.sy__label {
font-size: 11px;
font-weight: 700;
line-height: 1.2;
letter-spacing: .06em;
color: rgba(23, 20, 26, .62);
}
.sy__field {
position: relative;
display: block;
}
.sy__input {
display: block;
width: 100%;
height: 26px;
padding: 0 $sp-2;
border: 1px solid rgba(23, 20, 26, .22);
border-radius: $r-xs;
background: $stage-paper;
color: $subject-ink;
font-family: inherit;
font-size: 12px;
font-weight: 600;
line-height: 1.2;
}
.sy__input:focus-visible { outline: 3px solid $color; outline-offset: 1px; }
// 형식이 어긋난 칸만 붉게 남는다. 색만으로 알리지 않도록 옆에 글자도 함께 켠다
.sy__input[aria-invalid="true"] { border-color: $error; background: $error-soft; }
.sy__bad {
position: absolute;
right: 4px;
top: 50%;
transform: translateY(-50%);
padding: 0 $sp-1;
border-radius: $r-xs;
background: $error-soft;
color: $error;
font-size: 10px;
font-weight: 700;
line-height: 1.6;
opacity: 0; // 공간은 늘 차지한 채로 숨어 있다
pointer-events: none;
}
.sy__row.is-bad .sy__bad { opacity: 1; }
// 미리보기 글자 — 입력칸 글자와 같은 자리에 겹친다. 안쪽 여백·테두리 두께를 그대로 따라 맞춘다
.sy__ghost {
position: absolute;
left: 0;
top: 0;
display: flex;
align-items: center;
width: 100%;
height: 100%;
padding: 0 $sp-2;
border: 1px solid transparent;
font-size: 12px;
font-weight: 600;
line-height: 1.2;
opacity: 0;
pointer-events: none;
}
.sy__ghost--hex {
counter-set: cr calc(var(--r)) cg calc(var(--g)) cb calc(var(--b));
}
.sy__ghost--hex::after { content: "#" counter(cr, hx) counter(cg, hx) counter(cb, hx); }
.sy__ghost--rgb {
counter-set: cr calc(var(--r)) cg calc(var(--g)) cb calc(var(--b));
}
.sy__ghost--rgb::after { content: "rgb(" counter(cr) ", " counter(cg) ", " counter(cb) ")"; }
.sy__ghost--hsl {
counter-set: chh calc(var(--hh)) css calc(var(--ss)) cll calc(var(--ll));
}
.sy__ghost--hsl::after { content: "hsl(" counter(chh) ", " counter(css) "%, " counter(cll) "%)"; }
// ── 미리보기 루프(정적 규칙 먼저, 그 위에 덧씌움) ─────────────────
// 루프 동안만 진짜 입력칸 글자를 감추고(공간은 유지) 계수기 글자를 켠다
.sy.is-demo .sy__input { color: transparent; }
.sy.is-demo .sy__ghost { opacity: 1; }
.sy.is-demo {
animation-name: syColor;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
// 명도 50% → 24% → 되짚어 50%. 90% 에 제자리로 돌아온다(24프레임의 마지막 장이 95.8% 지점이다).
// 여섯 값이 한 키프레임 안에 있어 어느 프레임에서도 띠 색과 세 줄 글자가 같은 색을 가리킨다
@keyframes syColor {
0% { --ll: 50; --r: 13; --g: 120; --b: 242; }
45%, 55% { --ll: 24; --r: 6; --g: 58; --b: 116; }
90%, 100% { --ll: 50; --r: 13; --g: 120; --b: 242; }
}
@media (prefers-reduced-motion: reduce) {
.sy.is-demo { animation: none !important; }
.sy.is-demo .sy__input { color: $subject-ink !important; }
.sy.is-demo .sy__ghost { opacity: 0 !important; }
}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);
}
@property --r {
syntax: "<integer>";
inherits: true;
initial-value: 13;
}
@property --g {
syntax: "<integer>";
inherits: true;
initial-value: 120;
}
@property --b {
syntax: "<integer>";
inherits: true;
initial-value: 242;
}
@property --hh {
syntax: "<integer>";
inherits: true;
initial-value: 212;
}
@property --ss {
syntax: "<integer>";
inherits: true;
initial-value: 90;
}
@property --ll {
syntax: "<integer>";
inherits: true;
initial-value: 50;
}
@counter-style hx {
system: numeric;
symbols: "0" "1" "2" "3" "4" "5" "6" "7" "8" "9" "A" "B" "C" "D" "E" "F";
pad: 2 "0";
}
.sy {
box-sizing: border-box;
width: 288px;
max-width: 100%;
padding: 12px;
border-radius: 16px;
background: #fff7e6;
color: #17141a;
font-family: inherit;
}
.sy * {
box-sizing: border-box;
}
.sy__band {
height: 32px;
border-radius: 8px;
background: rgb(var(--r), var(--g), var(--b));
}
.sy__row {
display: grid;
grid-template-columns: 36px 1fr;
align-items: center;
gap: 8px;
margin-top: 4px;
}
.sy__label {
font-size: 11px;
font-weight: 700;
line-height: 1.2;
letter-spacing: 0.06em;
color: rgba(23, 20, 26, 0.62);
}
.sy__field {
position: relative;
display: block;
}
.sy__input {
display: block;
width: 100%;
height: 26px;
padding: 0 8px;
border: 1px solid rgba(23, 20, 26, 0.22);
border-radius: 4px;
background: #ffffff;
color: #17141a;
font-family: inherit;
font-size: 12px;
font-weight: 600;
line-height: 1.2;
}
.sy__input:focus-visible {
outline: 3px solid #2f6df6;
outline-offset: 1px;
}
.sy__input[aria-invalid=true] {
border-color: #c2361a;
background: #ffe0d8;
}
.sy__bad {
position: absolute;
right: 4px;
top: 50%;
transform: translateY(-50%);
padding: 0 4px;
border-radius: 4px;
background: #ffe0d8;
color: #c2361a;
font-size: 10px;
font-weight: 700;
line-height: 1.6;
opacity: 0;
pointer-events: none;
}
.sy__row.is-bad .sy__bad {
opacity: 1;
}
.sy__ghost {
position: absolute;
left: 0;
top: 0;
display: flex;
align-items: center;
width: 100%;
height: 100%;
padding: 0 8px;
border: 1px solid transparent;
font-size: 12px;
font-weight: 600;
line-height: 1.2;
opacity: 0;
pointer-events: none;
}
.sy__ghost--hex {
counter-set: cr calc(var(--r)) cg calc(var(--g)) cb calc(var(--b));
}
.sy__ghost--hex::after {
content: "#" counter(cr, hx) counter(cg, hx) counter(cb, hx);
}
.sy__ghost--rgb {
counter-set: cr calc(var(--r)) cg calc(var(--g)) cb calc(var(--b));
}
.sy__ghost--rgb::after {
content: "rgb(" counter(cr) ", " counter(cg) ", " counter(cb) ")";
}
.sy__ghost--hsl {
counter-set: chh calc(var(--hh)) css calc(var(--ss)) cll calc(var(--ll));
}
.sy__ghost--hsl::after {
content: "hsl(" counter(chh) ", " counter(css) "%, " counter(cll) "%)";
}
.sy.is-demo .sy__input {
color: transparent;
}
.sy.is-demo .sy__ghost {
opacity: 1;
}
.sy.is-demo {
animation-name: syColor;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes syColor {
0% {
--ll: 50;
--r: 13;
--g: 120;
--b: 242;
}
45%, 55% {
--ll: 24;
--r: 6;
--g: 58;
--b: 116;
}
90%, 100% {
--ll: 50;
--r: 13;
--g: 120;
--b: 242;
}
}
@media (prefers-reduced-motion: reduce) {
.sy.is-demo {
animation: none !important;
}
.sy.is-demo .sy__input {
color: #17141a !important;
}
.sy.is-demo .sy__ghost {
opacity: 0 !important;
}
}(function () {
var root = document.querySelector('.sy');
var rows = [].slice.call(document.querySelectorAll('.sy__row'));
var rgb = [13, 120, 242];
function pad(n) { return n.toString(16).padStart(2, '0'); }
function toHex(c) { return ('#' + pad(c[0]) + pad(c[1]) + pad(c[2])).toUpperCase(); }
// RGB(0~255) → HSL(0~360, 0~100%, 0~100%)
function toHsl(c) {
var r = c[0] / 255, g = c[1] / 255, b = c[2] / 255;
var mx = Math.max(r, g, b), mn = Math.min(r, g, b), d = mx - mn;
var l = (mx + mn) / 2;
var s = d === 0 ? 0 : d / (1 - Math.abs(2 * l - 1));
var h = 0;
if (d !== 0 && mx === r) { h = 60 * (((g - b) / d) % 6); }
else if (d !== 0 && mx === g) { h = 60 * ((b - r) / d + 2); }
else if (d !== 0) { h = 60 * ((r - g) / d + 4); }
return [Math.round((h + 360) % 360), Math.round(s * 100), Math.round(l * 100)];
}
// HSL → RGB. 육각형 여섯 구간을 표로 펴서 분기 없이 고른다
function fromHsl(h, s, l) {
var sf = s / 100, lf = l / 100;
var c = (1 - Math.abs(2 * lf - 1)) * sf;
var x = c * (1 - Math.abs(((h / 60) % 2) - 1));
var m = lf - c / 2;
var face = [[c, x, 0], [x, c, 0], [0, c, x], [0, x, c], [x, 0, c], [c, 0, x]][Math.floor(h / 60) % 6];
return face.map(function (n) { return Math.round((n + m) * 255); });
}
// 세 표기 중 하나를 읽는다. 형식이 어긋나면 null 을 돌려주고 그 칸만 aria-invalid 로 남는다
function parse(kind, raw) {
var t = raw.trim(), m, v;
if (kind === 'hex') {
m = /^#?([0-9a-f]{6})$/i.exec(t);
return m ? [0, 2, 4].map(function (i) { return parseInt(m[1].substr(i, 2), 16); }) : null;
}
if (kind === 'rgb') {
m = /^rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)$/i.exec(t);
if (!m) { return null; }
v = [1, 2, 3].map(function (i) { return parseInt(m[i], 10); });
return v.some(function (n) { return n > 255; }) ? null : v;
}
m = /^hsl\(\s*(\d{1,3})\s*,\s*(\d{1,3})%\s*,\s*(\d{1,3})%\s*\)$/i.exec(t);
if (!m) { return null; }
v = [1, 2, 3].map(function (i) { return parseInt(m[i], 10); });
return (v[0] > 360 || v[1] > 100 || v[2] > 100) ? null : fromHsl(v[0], v[1], v[2]);
}
function label(kind) {
var hsl = toHsl(rgb);
if (kind === 'hex') { return toHex(rgb); }
if (kind === 'rgb') { return 'rgb(' + rgb[0] + ', ' + rgb[1] + ', ' + rgb[2] + ')'; }
return 'hsl(' + hsl[0] + ', ' + hsl[1] + '%, ' + hsl[2] + '%)';
}
// 값 하나가 바뀌면 여섯 개의 등록 속성만 갱신한다 — 띠 색도 미리보기 글자도 거기서 나온다
function paint(skip) {
var hsl = toHsl(rgb);
var vars = { '--r': rgb[0], '--g': rgb[1], '--b': rgb[2], '--hh': hsl[0], '--ss': hsl[1], '--ll': hsl[2] };
Object.keys(vars).forEach(function (name) { root.style.setProperty(name, vars[name]); });
rows.forEach(function (row) {
var kind = row.getAttribute('data-kind');
if (kind === skip) { return; }
row.querySelector('.sy__input').value = label(kind);
row.querySelector('.sy__input').setAttribute('aria-invalid', 'false');
row.classList.remove('is-bad');
});
}
rows.forEach(function (row) {
var kind = row.getAttribute('data-kind');
var input = row.querySelector('.sy__input');
input.addEventListener('input', function () {
root.classList.remove('is-demo');
var next = parse(kind, input.value);
input.setAttribute('aria-invalid', next ? 'false' : 'true');
row.classList.toggle('is-bad', !next);
if (next) { rgb = next; paint(kind); }
});
});
paint(null);
})();// HexRgbHslSync — vanilla 판(run/286/demo/03-color-picker-hex-rgb-hsl-sync) 의 React 포트.
// 변수 3개(duration·easing·color)는 props 로 받아 CSS 변수(var(--duration) 등)로 들어온다.
// 상태는 RGB 세 숫자뿐이다. HSL 은 그때그때 계산해 --hh --ss --ll 로만 넘기고,
// 미리보기 글자는 module.scss 의 counter-set + @counter-style 이 찍는다 — JS 가 글자를 쓰지 않는다.
import { useState } from "react";
import type { CSSProperties, ChangeEvent } from "react";
import styles from "./HexRgbHslSync.module.scss";
export type HexRgbHslSyncProps = {
hexLabel?: string;
rgbLabel?: string;
hslLabel?: string;
badLabel?: string;
/** 격자 미리보기용 2초 자동 루프. 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
"--r"?: number;
"--g"?: number;
"--b"?: number;
"--hh"?: number;
"--ss"?: number;
"--ll"?: number;
};
const KINDS = ["hex", "rgb", "hsl"];
function pad(n: number): string {
return n.toString(16).padStart(2, "0");
}
function toHex(c: number[]): string {
return ("#" + pad(c[0]) + pad(c[1]) + pad(c[2])).toUpperCase();
}
// RGB(0~255) → HSL(0~360, 0~100%, 0~100%)
function toHsl(c: number[]): number[] {
const r = c[0] / 255;
const g = c[1] / 255;
const b = c[2] / 255;
const mx = Math.max(r, g, b);
const mn = Math.min(r, g, b);
const d = mx - mn;
const l = (mx + mn) / 2;
const s = d === 0 ? 0 : d / (1 - Math.abs(2 * l - 1));
let h = 0;
if (d !== 0 && mx === r) h = 60 * (((g - b) / d) % 6);
else if (d !== 0 && mx === g) h = 60 * ((b - r) / d + 2);
else if (d !== 0) h = 60 * ((r - g) / d + 4);
return [Math.round((h + 360) % 360), Math.round(s * 100), Math.round(l * 100)];
}
// HSL → RGB. 육각형 여섯 구간을 표로 펴서 분기 없이 고른다
function fromHsl(h: number, s: number, l: number): number[] {
const sf = s / 100;
const lf = l / 100;
const c = (1 - Math.abs(2 * lf - 1)) * sf;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m = lf - c / 2;
const faces: number[][] = [[c, x, 0], [x, c, 0], [0, c, x], [0, x, c], [x, 0, c], [c, 0, x]];
return faces[Math.floor(h / 60) % 6].map((n) => Math.round((n + m) * 255));
}
// 세 표기 중 하나를 읽는다. 형식이 어긋나면 null 을 돌려주고 그 칸만 aria-invalid 로 남는다
function parse(kind: string, raw: string): number[] | null {
const t = raw.trim();
if (kind === "hex") {
const m = /^#?([0-9a-f]{6})$/i.exec(t);
return m ? [0, 2, 4].map((i) => parseInt(m[1].substr(i, 2), 16)) : null;
}
if (kind === "rgb") {
const m = /^rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)$/i.exec(t);
if (!m) return null;
const v = [1, 2, 3].map((i) => parseInt(m[i], 10));
return v.some((n) => n > 255) ? null : v;
}
const m = /^hsl\(\s*(\d{1,3})\s*,\s*(\d{1,3})%\s*,\s*(\d{1,3})%\s*\)$/i.exec(t);
if (!m) return null;
const v = [1, 2, 3].map((i) => parseInt(m[i], 10));
return v[0] > 360 || v[1] > 100 || v[2] > 100 ? null : fromHsl(v[0], v[1], v[2]);
}
function label(kind: string, rgb: number[]): string {
if (kind === "hex") return toHex(rgb);
if (kind === "rgb") return `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`;
const hsl = toHsl(rgb);
return `hsl(${hsl[0]}, ${hsl[1]}%, ${hsl[2]}%)`;
}
export default function HexRgbHslSync({
hexLabel = "HEX 값 입력",
rgbLabel = "RGB 값 입력",
hslLabel = "HSL 값 입력",
badLabel = "형식 오류",
demo = true,
duration = 2000,
easing = "linear",
color = "#2f6df6",
}: HexRgbHslSyncProps) {
const [live, setLive] = useState(demo);
const [rgb, setRgb] = useState<number[]>([13, 120, 242]);
const [editing, setEditing] = useState(""); // 사람이 손대고 있는 칸
const [draft, setDraft] = useState(""); // 그 칸에 적힌 글자 그대로
const [bad, setBad] = useState(""); // 형식이 어긋난 칸
const hsl = toHsl(rgb);
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
"--r": rgb[0],
"--g": rgb[1],
"--b": rgb[2],
"--hh": hsl[0],
"--ss": hsl[1],
"--ll": hsl[2],
};
const labels: { [k: string]: string } = { hex: hexLabel, rgb: rgbLabel, hsl: hslLabel };
const ghosts: { [k: string]: string } = { hex: styles.ghostHex, rgb: styles.ghostRgb, hsl: styles.ghostHsl };
function change(kind: string, e: ChangeEvent<HTMLInputElement>) {
setLive(false);
const raw = e.target.value;
setEditing(kind);
setDraft(raw);
const next = parse(kind, raw);
if (next) {
setRgb(next);
setBad("");
} else {
setBad(kind);
}
}
return (
<div className={[styles.sy, live ? styles.isDemo : ""].filter(Boolean).join(" ")} style={vars}>
<div className={styles.band} aria-hidden="true" />
{KINDS.map((kind) => (
<div key={kind} className={[styles.row, bad === kind ? styles.isBad : ""].filter(Boolean).join(" ")}>
<span className={styles.label} aria-hidden="true">
{kind.toUpperCase()}
</span>
<span className={styles.field}>
<input
className={styles.input}
type="text"
spellCheck={false}
autoComplete="off"
aria-label={labels[kind]}
aria-invalid={bad === kind}
value={kind === editing ? draft : label(kind, rgb)}
onChange={(e: ChangeEvent<HTMLInputElement>) => change(kind, e)}
/>
<span className={[styles.ghost, ghosts[kind]].join(" ")} aria-hidden="true" />
<span className={styles.bad} aria-hidden="true">
{badLabel}
</span>
</span>
</div>
))}
</div>
);
}03 · HEX·RGB·HSL 세 칸이 서로 따라가는 입력 — 측정 누적면적 6.699% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
개발자 도구 색 입력이나 디자인 토큰 편집기에 쓰는 벌로, 누적 변화 면적 6.699% · 움직인 프레임 20/23 입니다.
// 미리보기 글자 — 값은 --r --g --b --hh --ss --ll 여섯 개뿐이고 계수기가 표기 셋으로 나눠 찍는다.
// 루프의 근거: 명도 l ≤ 0.5 구간에서 hsl(212, 90%, l) 의 RGB 는 (0.1l, 0.94l, 1.9l) × 255 라
// 명도에 완전히 비례한다 — 색상 212·채도 90% 고정이라 두 표기가 어느 프레임에서도 같은 색을 가리킨다
.sy.is-demo .sy__input { color: transparent; } // 진짜 입력칸은 공간을 유지한 채 감춘다
.sy.is-demo .sy__ghost { opacity: 1; }
.sy__ghost--hex::after { content: "#" counter(cr, hx) counter(cg, hx) counter(cb, hx); }
.sy__ghost--rgb::after { content: "rgb(" counter(cr) ", " counter(cg) ", " counter(cb) ")"; }
.sy__ghost--hsl::after { content: "hsl(" counter(chh) ", " counter(css) "%, " counter(cll) "%)"; }
04칸무늬 위에서 투명도를 미는 알파 슬라이더
무엇이 보이나
투명한 색은 흰 배경 위에서 보이지 않으므로, 알파 슬라이더는 칸무늬를 깔고 시작합니다. 손잡이를 왼쪽으로 되밀수록 색 덮개가 옅어져 뒤의 칸무늬가 드러나고, 무늬를 가린 만큼이 곧 알파 값입니다.

<main class="stage">
<div class="al is-demo">
<p class="al__head" aria-hidden="true">
<span class="al__cap">투명도</span><span class="al__val"></span>
</p>
<div class="al__track" id="alphaTrack" role="slider" tabindex="0"
aria-label="투명도 고르기"
aria-valuemin="0" aria-valuemax="100" aria-valuenow="100"
aria-valuetext="투명도 100%">
<span class="al__knob" aria-hidden="true"></span>
</div>
<p class="al__note" aria-hidden="true">칸무늬 위 미리보기</p>
<div class="al__preview" aria-hidden="true"><span class="al__fill"></span></div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 알파 슬라이더 — 아래층에 칸무늬를 깔고 그 위에 색을 덮는다. 손잡이를 왼쪽으로 밀수록
// 색이 옅어져 뒤 칸무늬가 드러나고, 무늬를 가린 만큼이 곧 알파 값이다(--a 가 그대로 덮개의 불투명도다).
// 움직이는 값은 --a(0~100) 하나뿐이다. 손잡이 위치도, 미리보기 사각형의 불투명도도,
// 옆의 숫자도 전부 그 한 값에서 나온다 — 숫자는 counter-set 이 계수기로 옮겨 찍으므로
// JS 가 글자를 따로 쓰지 않고, 어느 프레임에서도 손잡이 자리와 숫자가 어긋나지 않는다.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
@property --a { syntax: "<integer>"; inherits: true; initial-value: 100; }
.al {
box-sizing: border-box;
width: 288px;
max-width: 100%;
padding: $sp-3;
border-radius: $r-card;
background: $subject-cream;
color: $subject-ink;
font-family: inherit;
}
.al * { box-sizing: border-box; }
.al__head {
display: flex;
align-items: baseline;
gap: $sp-2;
margin: 0 0 $sp-2;
}
.al__cap {
font-size: 11px;
font-weight: 500;
line-height: 1.2;
color: rgba(23, 20, 26, .62);
}
// 숫자는 JS 가 쓰지 않는다 — counter-set 이 --a 를 계수기로 옮기고 ::after 가 찍는다
.al__val {
counter-set: ca calc(var(--a));
font-size: 13px;
font-weight: 700;
line-height: 1.2;
letter-spacing: .04em;
}
.al__val::after { content: counter(ca) "%"; }
.al__track {
position: relative;
width: 264px; // 카드 안쪽 폭 그대로 — 1% 가 2.64px 이다
max-width: 100%;
height: 20px;
border-radius: $r-pill;
cursor: pointer;
touch-action: none;
background-color: $stage-paper;
// 위층은 투명 → 불투명 색 덮개, 아래층은 12px 타일 칸무늬(타일 안이 네 칸이라 한 칸은 6px)
background-image:
linear-gradient(to right, rgba($subject-blue, 0), $subject-blue),
repeating-conic-gradient(#d8d4cc 0 25%, $stage-paper 0 50%);
background-size: auto, 12px 12px;
}
.al__track:focus-visible { outline: 3px solid $color; outline-offset: 3px; }
.al__knob {
position: absolute;
left: 0;
top: 50%;
width: 18px;
height: 18px;
border-radius: 50%;
background: $stage-paper;
border: 2px solid $subject-ink;
box-shadow: $shadow-press;
// 1% 는 2.64px. 자리로 옮긴 뒤 자기 크기의 절반만큼 되밀어 중심을 그 자리에 놓는다
transform: translateX(calc(var(--a) * 2.64px)) translate(-50%, -50%);
}
.al__note {
margin: $sp-2 0 $sp-1;
font-size: 11px;
font-weight: 500;
line-height: 1.2;
color: rgba(23, 20, 26, .62);
}
.al__preview {
position: relative;
height: 52px;
border-radius: $r-sm;
overflow: hidden;
background-color: $stage-paper;
background-image: repeating-conic-gradient(#d8d4cc 0 25%, $stage-paper 0 50%);
background-size: 12px 12px;
}
.al__fill {
position: absolute;
inset: 0;
background: $subject-blue;
// 알파를 색 안에 넣지 않고 덮개의 불투명도로 준다 — 값이 --a 한 곳에서만 온다
opacity: calc(var(--a) * 1%);
}
// ── 미리보기 루프(정적 규칙 먼저, 그 위에 덧씌움) ─────────────────
.al.is-demo {
animation-name: alSlide;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
// 100% → 24% → 같은 길을 되짚어 100%. 90% 에 제자리로 돌아온다
// (24프레임의 마지막 장이 95.8% 지점이라 그 뒤에 두면 복귀가 영영 안 찍힌다)
@keyframes alSlide {
0% { --a: 100; }
45% { --a: 24; }
90%, 100% { --a: 100; }
}
@media (prefers-reduced-motion: reduce) {
.al.is-demo { animation: none !important; }
}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);
}
@property --a {
syntax: "<integer>";
inherits: true;
initial-value: 100;
}
.al {
box-sizing: border-box;
width: 288px;
max-width: 100%;
padding: 12px;
border-radius: 16px;
background: #fff7e6;
color: #17141a;
font-family: inherit;
}
.al * {
box-sizing: border-box;
}
.al__head {
display: flex;
align-items: baseline;
gap: 8px;
margin: 0 0 8px;
}
.al__cap {
font-size: 11px;
font-weight: 500;
line-height: 1.2;
color: rgba(23, 20, 26, 0.62);
}
.al__val {
counter-set: ca calc(var(--a));
font-size: 13px;
font-weight: 700;
line-height: 1.2;
letter-spacing: 0.04em;
}
.al__val::after {
content: counter(ca) "%";
}
.al__track {
position: relative;
width: 264px;
max-width: 100%;
height: 20px;
border-radius: 999px;
cursor: pointer;
touch-action: none;
background-color: #ffffff;
background-image: linear-gradient(to right, rgba(47, 109, 246, 0), #2f6df6), repeating-conic-gradient(#d8d4cc 0 25%, #ffffff 0 50%);
background-size: auto, 12px 12px;
}
.al__track:focus-visible {
outline: 3px solid #2f6df6;
outline-offset: 3px;
}
.al__knob {
position: absolute;
left: 0;
top: 50%;
width: 18px;
height: 18px;
border-radius: 50%;
background: #ffffff;
border: 2px solid #17141a;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transform: translateX(calc(var(--a) * 2.64px)) translate(-50%, -50%);
}
.al__note {
margin: 8px 0 4px;
font-size: 11px;
font-weight: 500;
line-height: 1.2;
color: rgba(23, 20, 26, 0.62);
}
.al__preview {
position: relative;
height: 52px;
border-radius: 8px;
overflow: hidden;
background-color: #ffffff;
background-image: repeating-conic-gradient(#d8d4cc 0 25%, #ffffff 0 50%);
background-size: 12px 12px;
}
.al__fill {
position: absolute;
inset: 0;
background: #2f6df6;
opacity: calc(var(--a) * 1%);
}
.al.is-demo {
animation-name: alSlide;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes alSlide {
0% {
--a: 100;
}
45% {
--a: 24;
}
90%, 100% {
--a: 100;
}
}
@media (prefers-reduced-motion: reduce) {
.al.is-demo {
animation: none !important;
}
}(function () {
var root = document.querySelector('.al');
var track = document.getElementById('alphaTrack');
var alphaFormat = '투명도 %a%';
var alpha = 100;
// 값은 --a 하나뿐이다. 손잡이 위치도, 미리보기 사각형의 불투명도도, 옆의 숫자도 전부 이 한 값에서 나온다
function applyAlpha() {
root.style.setProperty('--a', alpha);
track.setAttribute('aria-valuenow', alpha);
track.setAttribute('aria-valuetext', alphaFormat.replace('%a', alpha));
}
function pickAlpha(e) {
var box = track.getBoundingClientRect();
var t = Math.min(1, Math.max(0, (e.clientX - box.left) / box.width));
alpha = Math.round(t * 100);
applyAlpha();
}
track.addEventListener('pointerdown', function (e) {
root.classList.remove('is-demo');
track.setPointerCapture(e.pointerId); // 띠 밖으로 끌어도 이 요소가 계속 포인터를 받는다
pickAlpha(e);
});
track.addEventListener('pointermove', function (e) {
if (track.hasPointerCapture(e.pointerId)) { pickAlpha(e); }
});
track.addEventListener('keydown', function (e) {
var step = e.shiftKey ? 10 : 2;
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') { alpha = Math.min(100, alpha + step); }
else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') { alpha = Math.max(0, alpha - step); }
else { return; }
e.preventDefault();
root.classList.remove('is-demo');
applyAlpha();
});
applyAlpha();
})();// AlphaCheckerboard — vanilla 판(run/286/demo/04-color-picker-alpha-checkerboard) 의 React 포트.
// 변수 3개(duration·easing·color)는 props 로 받아 CSS 변수(var(--duration) 등)로 들어온다.
// 상태는 alpha 숫자 하나뿐이다. 손잡이 위치도, 미리보기 사각형의 불투명도도, 옆의 숫자도
// 전부 --a 한 값에서 나오고 숫자는 module.scss 의 counter-set 이 찍는다 — JS 가 글자를 쓰지 않는다.
import { useState } from "react";
import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react";
import styles from "./AlphaCheckerboard.module.scss";
type KeyEvt = { key: string; shiftKey: boolean; preventDefault(): void };
export type AlphaCheckerboardProps = {
trackLabel?: string;
capLabel?: string;
noteLabel?: string;
/** "투명도 %a%" 처럼 자리표시자 하나를 가진 틀 */
alphaFormat?: string;
/** 격자 미리보기용 2초 자동 루프. 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
"--a"?: number;
};
export default function AlphaCheckerboard({
trackLabel = "투명도 고르기",
capLabel = "투명도",
noteLabel = "칸무늬 위 미리보기",
alphaFormat = "투명도 %a%",
demo = true,
duration = 2000,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#2f6df6",
}: AlphaCheckerboardProps) {
const [live, setLive] = useState(demo);
const [alpha, setAlpha] = useState(100);
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
"--a": alpha,
};
function alphaAt(e: ReactPointerEvent<HTMLDivElement>): number {
const box = e.currentTarget.getBoundingClientRect();
const t = Math.min(1, Math.max(0, (e.clientX - box.left) / box.width));
return Math.round(t * 100);
}
function start(e: ReactPointerEvent<HTMLDivElement>) {
setLive(false);
e.currentTarget.setPointerCapture(e.pointerId); // 띠 밖으로 끌어도 계속 포인터를 받는다
setAlpha(alphaAt(e));
}
function drag(e: ReactPointerEvent<HTMLDivElement>) {
if (e.currentTarget.hasPointerCapture(e.pointerId)) setAlpha(alphaAt(e));
}
function press(e: KeyEvt) {
const step = e.shiftKey ? 10 : 2;
if (e.key === "ArrowRight" || e.key === "ArrowUp") setAlpha(Math.min(100, alpha + step));
else if (e.key === "ArrowLeft" || e.key === "ArrowDown") setAlpha(Math.max(0, alpha - step));
else return;
e.preventDefault();
setLive(false);
}
const alphaText = alphaFormat.replace("%a", String(alpha));
return (
<div className={[styles.al, live ? styles.isDemo : ""].filter(Boolean).join(" ")} style={vars}>
<p className={styles.head} aria-hidden="true">
<span className={styles.cap}>{capLabel}</span>
<span className={styles.val} />
</p>
<div
className={styles.track}
role="slider"
tabIndex={0}
aria-label={trackLabel}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={alpha}
aria-valuetext={alphaText}
onPointerDown={start}
onPointerMove={drag}
onKeyDown={press}
>
<span className={styles.knob} aria-hidden="true" />
</div>
<p className={styles.note} aria-hidden="true">
{noteLabel}
</p>
<div className={styles.preview} aria-hidden="true">
<span className={styles.fill} />
</div>
</div>
);
}04 · 칸무늬 위에서 투명도를 미는 알파 슬라이더 — 측정 누적면적 13.06% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
반투명 배경 토큰이나 오버레이 색을 정할 때 눈으로 맞추는 벌로, 아홉 개 중 가장 넓게 움직여 누적 변화 면적 13.06% · 강도 87.5 · 움직인 프레임 18/23 입니다.
// 위층은 투명 → 불투명 색 덮개, 아래층은 12px 타일 칸무늬(타일 안이 네 칸이라 한 칸은 6px)
.al__track {
background-image:
linear-gradient(to right, rgba($subject-blue, 0), $subject-blue),
repeating-conic-gradient(#d8d4cc 0 25%, $stage-paper 0 50%);
background-size: auto, 12px 12px;
}
// 손잡이와 숫자가 --a 하나에서 나온다 — 264px 트랙의 1% 가 2.64px 이다
.al__knob { transform: translateX(calc(var(--a) * 2.64px)) translate(-50%, -50%); }
.al__val::after { content: counter(ca) "%"; }
05최근 고른 색 여덟 칸이 앞으로 밀리는 기록
화면에서 일어나는 일
고른 색을 다시 쓰게 해 주는 기록입니다. 담기를 누를 때마다 맨 앞에 칸이 하나 끼어들고 아홉 번째는 뒤로 밀려 사라지며, 목록은 localStorage 에 남아 페이지를 다시 열어도 그대로입니다.

<main class="stage">
<div class="rc is-demo">
<div class="rc__top">
<button class="rc__now" id="nowSwatch" type="button" aria-label="지금 색"></button>
<button class="rc__add" id="addButton" type="button">담기</button>
</div>
<p class="rc__cap" aria-hidden="true">최근 고른 색</p>
<div class="rc__hist">
<button class="rc__cell" type="button" aria-pressed="true" aria-label="최근 색"></button>
<button class="rc__cell" type="button" aria-pressed="false" aria-label="최근 색"></button>
<button class="rc__cell" type="button" aria-pressed="false" aria-label="최근 색"></button>
<button class="rc__cell" type="button" aria-pressed="false" aria-label="최근 색"></button>
<button class="rc__cell" type="button" aria-pressed="false" aria-label="최근 색"></button>
<button class="rc__cell" type="button" aria-pressed="false" aria-label="최근 색"></button>
<button class="rc__cell" type="button" aria-pressed="false" aria-label="최근 색"></button>
<button class="rc__cell" type="button" aria-pressed="false" aria-label="최근 색"></button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 최근 고른 색 여덟 칸 — 색을 담을 때마다 맨 앞에 한 칸이 끼어들고 아홉 번째는 잘려 나간다.
// 칸 색은 등록 속성 --c 하나로만 들어간다. 사람이 쓸 때는 JS 가 인라인으로 넣고,
// 격자 미리보기에서는 여덟 칸이 **같은 키프레임**을 animation-delay 만 0.25초씩 달리해서 돈다.
// 여덟 색이 0.25초마다 한 칸씩 옆으로 옮겨 가는 것이 곧 "앞에 하나 끼어들고 뒤가 밀린다" 이고,
// 2초 뒤 정확히 제자리로 돌아오므로 텔레포트가 없다. 색 교체는 steps(1, end) 라 반투명 유령 프레임이 없다.
$duration: $dur-loop;
$easing: $ease-pop;
$color: $subject-blue;
@include stage($stage-paper);
@property --c { syntax: "<color>"; inherits: false; initial-value: #{$subject-blue}; }
.rc {
box-sizing: border-box;
width: 288px;
max-width: 100%;
padding: $sp-3;
border-radius: $r-card;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
}
.rc * { box-sizing: border-box; }
.rc__top {
display: flex;
align-items: center;
gap: $sp-3;
}
.rc__now {
width: 48px;
height: 48px;
padding: 0;
border: 2px solid rgba(255, 247, 230, .28);
border-radius: $r-sm;
background: var(--c);
cursor: pointer;
}
.rc__now:focus-visible { outline: 3px solid $color; outline-offset: 3px; }
.rc__add {
height: 32px;
padding: 0 $sp-4;
border: 0;
border-radius: $r-pill;
background: $subject-cream;
color: $subject-ink;
font-family: inherit;
font-size: 13px;
font-weight: 700;
line-height: 1.2;
cursor: pointer;
}
.rc__add:focus-visible { outline: 3px solid $color; outline-offset: 3px; }
.rc__cap {
margin: $sp-3 0 $sp-1;
font-size: 11px;
font-weight: 500;
line-height: 1.2;
color: rgba(255, 247, 230, .66);
}
.rc__hist {
display: grid;
grid-template-columns: repeat(8, 26px);
gap: $sp-2;
}
.rc__cell {
width: 26px;
height: 26px;
padding: 0;
border: 0;
border-radius: $r-xs;
background: var(--c);
cursor: pointer;
}
// 고른 칸은 테두리 고리로 표시한다(색만으로 알리지 않도록 라벨에 HEX 값도 함께 읽어 준다)
.rc__cell[aria-pressed="true"] { box-shadow: 0 0 0 2px $subject-cream; }
.rc__cell:focus-visible { outline: 3px solid $color; outline-offset: 3px; }
// ── 미리보기 루프(정적 규칙 먼저, 그 위에 덧씌움) ─────────────────
.rc.is-demo .rc__cell,
.rc.is-demo .rc__now {
animation-name: histRoll;
animation-duration: $duration;
animation-timing-function: steps(1, end); // 색 교체는 한 프레임에 컷 — 크로스페이드는 유령을 남긴다
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
// 칸마다 0.25초씩 위상을 늦춘다. 음수 지연이라 첫 장부터 제 색으로 시작한다
// (-2s 는 한 주기 전이므로 0s 와 같은 위상이다. 지연을 뒤로 미루려면 주기만큼 당겨 써야 한다)
.rc.is-demo .rc__cell:nth-child(1) { animation-delay: -2s; }
.rc.is-demo .rc__cell:nth-child(2) { animation-delay: -1.75s; }
.rc.is-demo .rc__cell:nth-child(3) { animation-delay: -1.5s; }
.rc.is-demo .rc__cell:nth-child(4) { animation-delay: -1.25s; }
.rc.is-demo .rc__cell:nth-child(5) { animation-delay: -1s; }
.rc.is-demo .rc__cell:nth-child(6) { animation-delay: -0.75s; }
.rc.is-demo .rc__cell:nth-child(7) { animation-delay: -0.5s; }
.rc.is-demo .rc__cell:nth-child(8) { animation-delay: -0.25s; }
// 맨 앞 칸과 「지금 색」 사각형은 같은 위상이라 늘 같은 색이다.
// 컷만으로는 움직인 프레임이 모자라니 컷 직후 튀어오르는 보조 애니메이션을 겹친다 —
// transform 이라 중간 프레임이 반투명 유령이 되지 않는다. 커지는 건 자기 중심에서
.rc.is-demo .rc__cell:nth-child(1),
.rc.is-demo .rc__now {
animation-name: histRoll, histPop;
animation-duration: $duration, calc($duration / 8);
animation-timing-function: steps(1, end), $easing;
animation-iteration-count: infinite, infinite;
animation-delay: -2s, 0s;
animation-fill-mode: backwards, backwards;
}
@keyframes histRoll {
0% { --c: #{$subject-blue}; }
12.5% { --c: #{$subject-sky}; }
25% { --c: #{$subject-mint}; }
37.5% { --c: #{$stage-yellow}; }
50% { --c: #{$stage-orange}; }
62.5% { --c: #{$subject-pink}; }
75% { --c: #{$subject-lilac}; }
87.5% { --c: #{$subject-cream}; }
100% { --c: #{$subject-blue}; }
}
@keyframes histPop {
0% { transform: scale(.86); }
60%, 100% { transform: scale(1); }
}
@media (prefers-reduced-motion: reduce) {
.rc.is-demo .rc__cell,
.rc.is-demo .rc__now {
animation: none !important;
}
}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);
}
@property --c {
syntax: "<color>";
inherits: false;
initial-value: #2f6df6;
}
.rc {
box-sizing: border-box;
width: 288px;
max-width: 100%;
padding: 12px;
border-radius: 16px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
}
.rc * {
box-sizing: border-box;
}
.rc__top {
display: flex;
align-items: center;
gap: 12px;
}
.rc__now {
width: 48px;
height: 48px;
padding: 0;
border: 2px solid rgba(255, 247, 230, 0.28);
border-radius: 8px;
background: var(--c);
cursor: pointer;
}
.rc__now:focus-visible {
outline: 3px solid #2f6df6;
outline-offset: 3px;
}
.rc__add {
height: 32px;
padding: 0 16px;
border: 0;
border-radius: 999px;
background: #fff7e6;
color: #17141a;
font-family: inherit;
font-size: 13px;
font-weight: 700;
line-height: 1.2;
cursor: pointer;
}
.rc__add:focus-visible {
outline: 3px solid #2f6df6;
outline-offset: 3px;
}
.rc__cap {
margin: 12px 0 4px;
font-size: 11px;
font-weight: 500;
line-height: 1.2;
color: rgba(255, 247, 230, 0.66);
}
.rc__hist {
display: grid;
grid-template-columns: repeat(8, 26px);
gap: 8px;
}
.rc__cell {
width: 26px;
height: 26px;
padding: 0;
border: 0;
border-radius: 4px;
background: var(--c);
cursor: pointer;
}
.rc__cell[aria-pressed=true] {
box-shadow: 0 0 0 2px #fff7e6;
}
.rc__cell:focus-visible {
outline: 3px solid #2f6df6;
outline-offset: 3px;
}
.rc.is-demo .rc__cell,
.rc.is-demo .rc__now {
animation-name: histRoll;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.rc.is-demo .rc__cell:nth-child(1) {
animation-delay: -2s;
}
.rc.is-demo .rc__cell:nth-child(2) {
animation-delay: -1.75s;
}
.rc.is-demo .rc__cell:nth-child(3) {
animation-delay: -1.5s;
}
.rc.is-demo .rc__cell:nth-child(4) {
animation-delay: -1.25s;
}
.rc.is-demo .rc__cell:nth-child(5) {
animation-delay: -1s;
}
.rc.is-demo .rc__cell:nth-child(6) {
animation-delay: -0.75s;
}
.rc.is-demo .rc__cell:nth-child(7) {
animation-delay: -0.5s;
}
.rc.is-demo .rc__cell:nth-child(8) {
animation-delay: -0.25s;
}
.rc.is-demo .rc__cell:nth-child(1),
.rc.is-demo .rc__now {
animation-name: histRoll, histPop;
animation-duration: 2s, 0.25s;
animation-timing-function: steps(1, end), cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite, infinite;
animation-delay: -2s, 0s;
animation-fill-mode: backwards, backwards;
}
@keyframes histRoll {
0% {
--c: #2f6df6;
}
12.5% {
--c: #6ec8ff;
}
25% {
--c: #4cd4a6;
}
37.5% {
--c: #ffd23f;
}
50% {
--c: #ff4d1f;
}
62.5% {
--c: #ff9ebb;
}
75% {
--c: #b9a0ff;
}
87.5% {
--c: #fff7e6;
}
100% {
--c: #2f6df6;
}
}
@keyframes histPop {
0% {
transform: scale(0.86);
}
60%, 100% {
transform: scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.rc.is-demo .rc__cell,
.rc.is-demo .rc__now {
animation: none !important;
}
}(function () {
var root = document.querySelector('.rc');
var now = document.getElementById('nowSwatch');
var addButton = document.getElementById('addButton');
var cells = [].slice.call(document.querySelectorAll('.rc__cell'));
var nowFormat = '지금 색 %x, 누르면 다음 색';
var cellFormat = '최근 색 %x';
var palette = ['#2F6DF6', '#6EC8FF', '#4CD4A6', '#FFD23F', '#FF4D1F', '#FF9EBB', '#B9A0FF', '#FFF7E6'];
var storeKey = 'godrich-recent-colors';
var step = 0;
var recent = read();
// 목록은 localStorage 에 남는다. 파일로 열면 저장소가 막힐 수 있어 실패해도 기본 팔레트로 이어간다
function read() {
try {
var raw = window.localStorage.getItem(storeKey);
var list = raw ? JSON.parse(raw) : null;
if (list && list.length) { return list.slice(0, 8); }
} catch (err) { /* 저장소를 못 쓰는 환경 */ }
return palette.slice();
}
function save(list) {
try { window.localStorage.setItem(storeKey, JSON.stringify(list)); } catch (err) { /* 무시 */ }
}
// 글자도 색도 JS 가 한 곳에서만 쓴다 — 칸 색은 --c, 라벨은 같은 HEX 값에서 나온다
function paint() {
now.style.setProperty('--c', palette[step]);
now.setAttribute('aria-label', nowFormat.replace('%x', palette[step]));
cells.forEach(function (cell, i) {
var hex = recent[i] || palette[i];
cell.style.setProperty('--c', hex);
cell.setAttribute('aria-label', cellFormat.replace('%x', hex));
cell.setAttribute('aria-pressed', hex === palette[step] ? 'true' : 'false');
});
}
function stopLoop() { root.classList.remove('is-demo'); }
now.addEventListener('click', function () {
stopLoop();
step = (step + 1) % palette.length;
paint();
});
addButton.addEventListener('click', function () {
stopLoop();
// 지금 색이 맨 앞에 끼어들고 아홉 번째는 잘려 나간다. 저장한 뒤 다시 읽어 칸을 그린다
var next = [palette[step]].concat(recent.filter(function (hex) { return hex !== palette[step]; }));
save(next.slice(0, 8));
recent = read();
paint();
});
cells.forEach(function (cell, i) {
cell.addEventListener('click', function () {
stopLoop();
var at = palette.indexOf(recent[i]);
if (at >= 0) { step = at; }
paint();
});
});
paint();
})();// RecentHistory — vanilla 판(run/286/demo/05-color-picker-recent-history) 의 React 포트.
// 변수 3개(duration·easing·color)는 props 로 받아 CSS 변수(var(--duration) 등)로 들어온다.
// 칸 색은 등록 속성 --c 하나로만 들어간다. 사람이 쓸 때는 인라인 스타일로,
// 격자 미리보기에서는 module.scss 의 키프레임이 같은 --c 를 덮어쓴다 — 한 경로로 둘 다 된다.
import { useEffect, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./RecentHistory.module.scss";
export type RecentHistoryProps = {
addLabel?: string;
capLabel?: string;
/** "지금 색 %x, 누르면 다음 색" 처럼 자리표시자 하나를 가진 틀 */
nowFormat?: string;
/** "최근 색 %x" 처럼 자리표시자 하나를 가진 틀 */
cellFormat?: string;
/** 격자 미리보기용 2초 자동 루프. 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
};
type Swatch = CSSProperties & { "--c"?: string };
const PALETTE = ["#2F6DF6", "#6EC8FF", "#4CD4A6", "#FFD23F", "#FF4D1F", "#FF9EBB", "#B9A0FF", "#FFF7E6"];
const STORE_KEY = "godrich-recent-colors";
// 목록은 localStorage 에 남는다. 저장소가 막힌 환경에서도 기본 팔레트로 이어간다
function read(): string[] {
try {
const raw = window.localStorage.getItem(STORE_KEY);
const list: string[] | null = raw ? JSON.parse(raw) : null;
if (list && list.length) return list.slice(0, 8);
} catch (err) {
/* 저장소를 못 쓰는 환경 */
}
return PALETTE.slice();
}
function save(list: string[]) {
try {
window.localStorage.setItem(STORE_KEY, JSON.stringify(list));
} catch (err) {
/* 무시 */
}
}
export default function RecentHistory({
addLabel = "담기",
capLabel = "최근 고른 색",
nowFormat = "지금 색 %x, 누르면 다음 색",
cellFormat = "최근 색 %x",
demo = true,
duration = 2000,
easing = "cubic-bezier(.2, 1.4, .4, 1)",
color = "#2f6df6",
}: RecentHistoryProps) {
const [live, setLive] = useState(demo);
const [step, setStep] = useState(0);
const [recent, setRecent] = useState<string[]>(PALETTE.slice());
// 서버 렌더에서는 저장소가 없으므로 붙은 뒤에 한 번 읽는다
useEffect(() => {
setRecent(read());
}, []);
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
};
const nowColor = PALETTE[step];
function add() {
setLive(false);
// 지금 색이 맨 앞에 끼어들고 아홉 번째는 잘려 나간다. 저장한 뒤 다시 읽어 칸을 그린다
const next = [nowColor].concat(recent.filter((hex) => hex !== nowColor)).slice(0, 8);
save(next);
setRecent(read());
}
function pick(hex: string) {
setLive(false);
const at = PALETTE.indexOf(hex);
if (at >= 0) setStep(at);
}
return (
<div className={[styles.rc, live ? styles.isDemo : ""].filter(Boolean).join(" ")} style={vars}>
<div className={styles.top}>
<button
className={styles.now}
type="button"
aria-label={nowFormat.replace("%x", nowColor)}
style={{ "--c": nowColor } as Swatch}
onClick={() => {
setLive(false);
setStep((step + 1) % PALETTE.length);
}}
/>
<button className={styles.add} type="button" onClick={add}>
{addLabel}
</button>
</div>
<p className={styles.cap} aria-hidden="true">
{capLabel}
</p>
<div className={styles.hist}>
{recent.map((hex, i) => (
<button
key={`${hex}-${i}`}
className={styles.cell}
type="button"
aria-pressed={hex === nowColor}
aria-label={cellFormat.replace("%x", hex)}
style={{ "--c": hex } as Swatch}
onClick={() => pick(hex)}
/>
))}
</div>
</div>
);
}05 · 최근 고른 색 여덟 칸이 앞으로 밀리는 기록 — 측정 누적면적 5.633% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
여덟 칸이 0.25 초씩 위상이 어긋나며 색이 옆으로 흐르는 미리보기는 움직인 프레임 23/23 을 채웠습니다.
// 목록은 localStorage 에 남는다. 파일로 열면 저장소가 막힐 수 있어 실패해도 기본 팔레트로 이어간다
function read() {
try {
var list = JSON.parse(window.localStorage.getItem(storeKey));
if (list && list.length) { return list.slice(0, 8); }
} catch (err) { /* 저장소를 못 쓰는 환경 */ }
return palette.slice();
}
recent.unshift(nowColor);
recent = recent.slice(0, 8);
// 칸마다 animation-delay 를 -0.25s 씩 다르게. 여덟 색이 0.25초마다 한 칸씩 옮겨 가는 것이
// 곧 "앞에 하나 끼어들고 뒤가 밀린다" 이고, 2초 뒤 정확히 제자리라 텔레포트가 없다
.rc.is-demo .rc__cell:nth-child(2) { animation-delay: -1.75s; }
@keyframes histRoll {
0% { --c: #{$subject-blue}; }
12.5% { --c: #{$subject-sky}; }
25% { --c: #{$subject-mint}; }
50% { --c: #{$stage-orange}; }
100% { --c: #{$subject-blue}; }
}
06색상값을 돌려 만드는 보색·유사색·삼각 배색
어떤 움직임인가
팔레트 초안 뽑기입니다. 기준 색상값에 보색은 180 도, 유사색은 ±30·±60 도, 삼각은 ±120 도를 더해 다섯 칩을 만듭니다.

<main class="stage">
<div class="hm is-demo">
<div class="hm__top">
<div class="hm__base" id="hmBase" role="slider" tabindex="0"
aria-label="기준 색상값"
aria-valuemin="0" aria-valuemax="360" aria-valuenow="262"
aria-valuetext="기준 색상값 262도"></div>
<span class="hm__basev" aria-hidden="true"></span>
<span class="hm__names" aria-hidden="true">
<b class="hm__name hm__name--1 is-on">보색 배색</b>
<b class="hm__name hm__name--2">유사색 배색</b>
<b class="hm__name hm__name--3">삼각 배색</b>
</span>
</div>
<div class="hm__tabs" role="group" aria-label="배색 고르기">
<button type="button" class="hm__tab" data-set="comp" aria-pressed="true">보색</button>
<button type="button" class="hm__tab" data-set="ana" aria-pressed="false">유사색</button>
<button type="button" class="hm__tab" data-set="tri" aria-pressed="false">삼각</button>
</div>
<ul class="hm__chips">
<li class="hm__chip"><span class="hm__sw"></span><span class="hm__deg"></span></li>
<li class="hm__chip"><span class="hm__sw"></span><span class="hm__deg"></span></li>
<li class="hm__chip"><span class="hm__sw"></span><span class="hm__deg"></span></li>
<li class="hm__chip"><span class="hm__sw"></span><span class="hm__deg"></span></li>
<li class="hm__chip"><span class="hm__sw"></span><span class="hm__deg"></span></li>
</ul>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 배색 생성기 — 기준 색상값 하나에서 보색·유사색·삼각 배색을 만든다.
// 색을 다섯 번 계산하지 않는다. 칩마다 "기준에 더할 각도" 하나(--o1 ~ --o5)만 두고
// hsl(calc(var(--base) + var(--o1)) 68% 70%) 로 그 자리에서 색을 만든다. 각도 글자도 같은 값에서
// 계수기가 찍으므로 칩 색과 각도 숫자가 어긋날 수가 없다.
// 배색을 바꾸는 것 = 각도 다섯 개를 한 프레임에 통째로 갈아끼우는 것이라 steps(1, end) 로 컷한다.
// 컷만 있으면 움직인 프레임이 두세 장으로 떨어지니, 칩마다 40ms 씩 밀린 scale 팝을 덧붙여 프레임을 채운다.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-orange);
// inherits: true 로 등록해야 칩(자손)이 물려받아 hsl()·counter-set 에 쓸 수 있다.
// inherits: false 로 등록하면 값이 .hm 자기 자신에만 남아 칩이 못 읽는다
@property --base { syntax: "<integer>"; inherits: true; initial-value: 262; }
@property --o1 { syntax: "<integer>"; inherits: true; initial-value: 0; }
@property --o2 { syntax: "<integer>"; inherits: true; initial-value: 0; }
@property --o3 { syntax: "<integer>"; inherits: true; initial-value: 0; }
@property --o4 { syntax: "<integer>"; inherits: true; initial-value: 180; }
@property --o5 { syntax: "<integer>"; inherits: true; initial-value: 180; }
.hm {
box-sizing: border-box;
width: 288px;
max-width: 100%;
padding: $sp-3;
border-radius: $r-card;
background: $subject-cream;
color: $subject-ink;
font-family: inherit;
}
.hm * { box-sizing: border-box; }
.hm__top {
display: flex;
align-items: center;
gap: $sp-2;
}
.hm__base {
width: 24px;
height: 24px;
border-radius: $r-xs;
cursor: ew-resize;
background: hsl(var(--base) 68% 52%);
}
.hm__base:focus-visible { outline: 3px solid $color; outline-offset: 3px; }
// 기준 색상값 숫자 — 색을 만든 그 값을 그대로 찍는다
.hm__basev {
counter-set: cbs calc(var(--base));
font-size: 11px;
font-weight: 700;
line-height: 1.2;
}
.hm__basev::after { content: counter(cbs) "°"; }
// 배색 이름 셋을 격자 한 칸에 겹쳐 두고 opacity 를 컷한다 (position: absolute 아님 — 칸 폭이 살아 있다)
.hm__names {
margin-left: auto;
display: grid;
justify-items: end;
}
.hm__name {
grid-area: 1 / 1;
opacity: 0;
white-space: nowrap;
font-size: 12px;
font-weight: 700;
line-height: 1.2;
}
.hm__name.is-on { opacity: 1; }
.hm__tabs {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: $sp-2;
margin-top: $sp-2;
}
.hm__tab {
width: 100%;
padding: $sp-1 0;
border: 2px solid rgba(23, 20, 26, .16);
border-radius: $r-pill;
background: transparent;
color: inherit;
cursor: pointer;
font-family: inherit;
font-size: 11px;
font-weight: 700;
line-height: 1.2;
}
.hm__tab[aria-pressed="true"] {
border-color: $subject-ink;
background: $subject-ink;
color: $subject-cream;
}
.hm__tab:focus-visible { outline: 3px solid $color; outline-offset: 3px; }
.hm__chips {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: $sp-2;
margin: $sp-2 0 0;
padding: 0;
list-style: none;
}
.hm__chip {
display: grid;
gap: $sp-1;
justify-items: center;
}
.hm__sw {
width: 100%;
height: 28px;
border-radius: $r-xs;
}
.hm__deg {
font-size: 10px;
font-weight: 600;
line-height: 1.2;
color: rgba(23, 20, 26, .7);
}
.hm__deg::after { content: counter(cd) "°"; }
// 칩 다섯 개 — 색도 각도 글자도 같은 --oN 하나에서 나온다
.hm__chip:nth-child(1) .hm__sw { background: hsl(calc(var(--base) + var(--o1)) 68% 70%); }
.hm__chip:nth-child(2) .hm__sw { background: hsl(calc(var(--base) + var(--o2)) 68% 58%); }
.hm__chip:nth-child(3) .hm__sw { background: hsl(calc(var(--base) + var(--o3)) 68% 48%); }
.hm__chip:nth-child(4) .hm__sw { background: hsl(calc(var(--base) + var(--o4)) 68% 58%); }
.hm__chip:nth-child(5) .hm__sw { background: hsl(calc(var(--base) + var(--o5)) 68% 70%); }
.hm__chip:nth-child(1) .hm__deg { counter-set: cd calc(var(--o1)); }
.hm__chip:nth-child(2) .hm__deg { counter-set: cd calc(var(--o2)); }
.hm__chip:nth-child(3) .hm__deg { counter-set: cd calc(var(--o3)); }
.hm__chip:nth-child(4) .hm__deg { counter-set: cd calc(var(--o4)); }
.hm__chip:nth-child(5) .hm__deg { counter-set: cd calc(var(--o5)); }
// ── 미리보기 루프(정적 규칙 먼저, 그 위에 덧씌움) ─────────────────
// 각도 다섯 개와 이름 셋을 같은 퍼센트(0 · 33 · 66 · 90)에서 한 프레임에 컷한다.
// 크로스페이드를 쓰면 그 중간 프레임이 반투명 유령으로 poster 에 뽑힌다
.hm.is-demo {
animation-name: hmSet;
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.hm.is-demo .hm__name--1 { animation-name: hmName1; }
.hm.is-demo .hm__name--2 { animation-name: hmName2; }
.hm.is-demo .hm__name--3 { animation-name: hmName3; }
.hm.is-demo .hm__name {
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
// 눌린 탭도 같은 순간에 옮겨 붙는다 — 루프가 보여주는 배색과 눌린 탭이 어긋나면 화면이 거짓말을 한다.
// 애니메이션은 [aria-pressed] 정적 규칙을 이기고, 루프를 떼면 다시 정적 규칙이 보인다
.hm.is-demo .hm__tab:nth-child(1) { animation-name: hmTab1; }
.hm.is-demo .hm__tab:nth-child(2) { animation-name: hmTab2; }
.hm.is-demo .hm__tab:nth-child(3) { animation-name: hmTab3; }
.hm.is-demo .hm__tab {
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
// 칩 팝 — 컷 직후 다섯 개가 차례로(0·40·80·120·160ms) 튀어올라 컷 사이 프레임을 채운다.
// 지연이 걸린 루프라 animation-fill-mode: backwards 가 없으면 0프레임에 작성 상태가 한 번 번쩍인다
.hm.is-demo .hm__chip {
animation-name: hmPop;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.hm.is-demo .hm__chip:nth-child(2) { animation-delay: 40ms; }
.hm.is-demo .hm__chip:nth-child(3) { animation-delay: 80ms; }
.hm.is-demo .hm__chip:nth-child(4) { animation-delay: 120ms; }
.hm.is-demo .hm__chip:nth-child(5) { animation-delay: 160ms; }
// 보색(0 0 0 180 180) → 유사색(-60 -30 0 30 60) → 삼각(-120 -120 0 120 120) → 90% 에 보색으로 복귀.
// 24프레임의 마지막 장이 95.8% 지점이라 복귀는 90% 안에 끝내야 촬영된다
@keyframes hmSet {
0% { --o1: 0; --o2: 0; --o3: 0; --o4: 180; --o5: 180; }
33% { --o1: -60; --o2: -30; --o3: 0; --o4: 30; --o5: 60; }
66% { --o1: -120; --o2: -120; --o3: 0; --o4: 120; --o5: 120; }
90%, 100% { --o1: 0; --o2: 0; --o3: 0; --o4: 180; --o5: 180; }
}
@keyframes hmName1 {
0% { opacity: 1; }
33% { opacity: 0; }
90%, 100% { opacity: 1; }
}
@keyframes hmName2 {
0% { opacity: 0; }
33% { opacity: 1; }
66% { opacity: 0; }
100% { opacity: 0; }
}
@keyframes hmName3 {
0% { opacity: 0; }
66% { opacity: 1; }
90% { opacity: 0; }
100% { opacity: 0; }
}
// 탭 셋의 눌림 상태도 같은 퍼센트에서 컷한다. 세 벌을 한 묶음으로 적어 두면 값이 갈릴 일이 없다
@keyframes hmTab1 {
0% { border-color: $subject-ink; background-color: $subject-ink; color: $subject-cream; }
33% { border-color: rgba(23, 20, 26, .16); background-color: transparent; color: $subject-ink; }
90%, 100% { border-color: $subject-ink; background-color: $subject-ink; color: $subject-cream; }
}
@keyframes hmTab2 {
0% { border-color: rgba(23, 20, 26, .16); background-color: transparent; color: $subject-ink; }
33% { border-color: $subject-ink; background-color: $subject-ink; color: $subject-cream; }
66% { border-color: rgba(23, 20, 26, .16); background-color: transparent; color: $subject-ink; }
100% { border-color: rgba(23, 20, 26, .16); background-color: transparent; color: $subject-ink; }
}
@keyframes hmTab3 {
0% { border-color: rgba(23, 20, 26, .16); background-color: transparent; color: $subject-ink; }
66% { border-color: $subject-ink; background-color: $subject-ink; color: $subject-cream; }
90% { border-color: rgba(23, 20, 26, .16); background-color: transparent; color: $subject-ink; }
100% { border-color: rgba(23, 20, 26, .16); background-color: transparent; color: $subject-ink; }
}
// 칩은 자기 중심에서 커진다 — transform-origin 기본값(50% 50%) 그대로 둔다
@keyframes hmPop {
0% { transform: scale(.86); }
7% { transform: scale(1.06); }
13%, 31% { transform: scale(1); }
33% { transform: scale(.86); }
40% { transform: scale(1.06); }
46%, 64% { transform: scale(1); }
66% { transform: scale(.86); }
73% { transform: scale(1.06); }
79%, 88% { transform: scale(1); }
90% { transform: scale(.86); }
96% { transform: scale(1.04); }
100% { transform: scale(1); }
}
@media (prefers-reduced-motion: reduce) {
.hm.is-demo,
.hm.is-demo .hm__name,
.hm.is-demo .hm__tab,
.hm.is-demo .hm__chip {
animation: none !important;
}
.hm.is-demo .hm__name { opacity: 0 !important; }
.hm.is-demo .hm__name.is-on { opacity: 1 !important; }
}@charset "UTF-8";
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);
}
@property --base {
syntax: "<integer>";
inherits: true;
initial-value: 262;
}
@property --o1 {
syntax: "<integer>";
inherits: true;
initial-value: 0;
}
@property --o2 {
syntax: "<integer>";
inherits: true;
initial-value: 0;
}
@property --o3 {
syntax: "<integer>";
inherits: true;
initial-value: 0;
}
@property --o4 {
syntax: "<integer>";
inherits: true;
initial-value: 180;
}
@property --o5 {
syntax: "<integer>";
inherits: true;
initial-value: 180;
}
.hm {
box-sizing: border-box;
width: 288px;
max-width: 100%;
padding: 12px;
border-radius: 16px;
background: #fff7e6;
color: #17141a;
font-family: inherit;
}
.hm * {
box-sizing: border-box;
}
.hm__top {
display: flex;
align-items: center;
gap: 8px;
}
.hm__base {
width: 24px;
height: 24px;
border-radius: 4px;
cursor: ew-resize;
background: hsl(var(--base), 68%, 52%);
}
.hm__base:focus-visible {
outline: 3px solid #2f6df6;
outline-offset: 3px;
}
.hm__basev {
counter-set: cbs calc(var(--base));
font-size: 11px;
font-weight: 700;
line-height: 1.2;
}
.hm__basev::after {
content: counter(cbs) "°";
}
.hm__names {
margin-left: auto;
display: grid;
justify-items: end;
}
.hm__name {
grid-area: 1/1;
opacity: 0;
white-space: nowrap;
font-size: 12px;
font-weight: 700;
line-height: 1.2;
}
.hm__name.is-on {
opacity: 1;
}
.hm__tabs {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
margin-top: 8px;
}
.hm__tab {
width: 100%;
padding: 4px 0;
border: 2px solid rgba(23, 20, 26, 0.16);
border-radius: 999px;
background: transparent;
color: inherit;
cursor: pointer;
font-family: inherit;
font-size: 11px;
font-weight: 700;
line-height: 1.2;
}
.hm__tab[aria-pressed=true] {
border-color: #17141a;
background: #17141a;
color: #fff7e6;
}
.hm__tab:focus-visible {
outline: 3px solid #2f6df6;
outline-offset: 3px;
}
.hm__chips {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 8px;
margin: 8px 0 0;
padding: 0;
list-style: none;
}
.hm__chip {
display: grid;
gap: 4px;
justify-items: center;
}
.hm__sw {
width: 100%;
height: 28px;
border-radius: 4px;
}
.hm__deg {
font-size: 10px;
font-weight: 600;
line-height: 1.2;
color: rgba(23, 20, 26, 0.7);
}
.hm__deg::after {
content: counter(cd) "°";
}
.hm__chip:nth-child(1) .hm__sw {
background: hsl(calc(var(--base) + var(--o1)), 68%, 70%);
}
.hm__chip:nth-child(2) .hm__sw {
background: hsl(calc(var(--base) + var(--o2)), 68%, 58%);
}
.hm__chip:nth-child(3) .hm__sw {
background: hsl(calc(var(--base) + var(--o3)), 68%, 48%);
}
.hm__chip:nth-child(4) .hm__sw {
background: hsl(calc(var(--base) + var(--o4)), 68%, 58%);
}
.hm__chip:nth-child(5) .hm__sw {
background: hsl(calc(var(--base) + var(--o5)), 68%, 70%);
}
.hm__chip:nth-child(1) .hm__deg {
counter-set: cd calc(var(--o1));
}
.hm__chip:nth-child(2) .hm__deg {
counter-set: cd calc(var(--o2));
}
.hm__chip:nth-child(3) .hm__deg {
counter-set: cd calc(var(--o3));
}
.hm__chip:nth-child(4) .hm__deg {
counter-set: cd calc(var(--o4));
}
.hm__chip:nth-child(5) .hm__deg {
counter-set: cd calc(var(--o5));
}
.hm.is-demo {
animation-name: hmSet;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.hm.is-demo .hm__name--1 {
animation-name: hmName1;
}
.hm.is-demo .hm__name--2 {
animation-name: hmName2;
}
.hm.is-demo .hm__name--3 {
animation-name: hmName3;
}
.hm.is-demo .hm__name {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.hm.is-demo .hm__tab:nth-child(1) {
animation-name: hmTab1;
}
.hm.is-demo .hm__tab:nth-child(2) {
animation-name: hmTab2;
}
.hm.is-demo .hm__tab:nth-child(3) {
animation-name: hmTab3;
}
.hm.is-demo .hm__tab {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.hm.is-demo .hm__chip {
animation-name: hmPop;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.hm.is-demo .hm__chip:nth-child(2) {
animation-delay: 40ms;
}
.hm.is-demo .hm__chip:nth-child(3) {
animation-delay: 80ms;
}
.hm.is-demo .hm__chip:nth-child(4) {
animation-delay: 120ms;
}
.hm.is-demo .hm__chip:nth-child(5) {
animation-delay: 160ms;
}
@keyframes hmSet {
0% {
--o1: 0;
--o2: 0;
--o3: 0;
--o4: 180;
--o5: 180;
}
33% {
--o1: -60;
--o2: -30;
--o3: 0;
--o4: 30;
--o5: 60;
}
66% {
--o1: -120;
--o2: -120;
--o3: 0;
--o4: 120;
--o5: 120;
}
90%, 100% {
--o1: 0;
--o2: 0;
--o3: 0;
--o4: 180;
--o5: 180;
}
}
@keyframes hmName1 {
0% {
opacity: 1;
}
33% {
opacity: 0;
}
90%, 100% {
opacity: 1;
}
}
@keyframes hmName2 {
0% {
opacity: 0;
}
33% {
opacity: 1;
}
66% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes hmName3 {
0% {
opacity: 0;
}
66% {
opacity: 1;
}
90% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes hmTab1 {
0% {
border-color: #17141a;
background-color: #17141a;
color: #fff7e6;
}
33% {
border-color: rgba(23, 20, 26, 0.16);
background-color: transparent;
color: #17141a;
}
90%, 100% {
border-color: #17141a;
background-color: #17141a;
color: #fff7e6;
}
}
@keyframes hmTab2 {
0% {
border-color: rgba(23, 20, 26, 0.16);
background-color: transparent;
color: #17141a;
}
33% {
border-color: #17141a;
background-color: #17141a;
color: #fff7e6;
}
66% {
border-color: rgba(23, 20, 26, 0.16);
background-color: transparent;
color: #17141a;
}
100% {
border-color: rgba(23, 20, 26, 0.16);
background-color: transparent;
color: #17141a;
}
}
@keyframes hmTab3 {
0% {
border-color: rgba(23, 20, 26, 0.16);
background-color: transparent;
color: #17141a;
}
66% {
border-color: #17141a;
background-color: #17141a;
color: #fff7e6;
}
90% {
border-color: rgba(23, 20, 26, 0.16);
background-color: transparent;
color: #17141a;
}
100% {
border-color: rgba(23, 20, 26, 0.16);
background-color: transparent;
color: #17141a;
}
}
@keyframes hmPop {
0% {
transform: scale(0.86);
}
7% {
transform: scale(1.06);
}
13%, 31% {
transform: scale(1);
}
33% {
transform: scale(0.86);
}
40% {
transform: scale(1.06);
}
46%, 64% {
transform: scale(1);
}
66% {
transform: scale(0.86);
}
73% {
transform: scale(1.06);
}
79%, 88% {
transform: scale(1);
}
90% {
transform: scale(0.86);
}
96% {
transform: scale(1.04);
}
100% {
transform: scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.hm.is-demo,
.hm.is-demo .hm__name,
.hm.is-demo .hm__tab,
.hm.is-demo .hm__chip {
animation: none !important;
}
.hm.is-demo .hm__name {
opacity: 0 !important;
}
.hm.is-demo .hm__name.is-on {
opacity: 1 !important;
}
}(function () {
var root = document.querySelector('.hm');
var base = document.getElementById('hmBase');
var tabs = [].slice.call(root.querySelectorAll('.hm__tab'));
var names = [].slice.call(root.querySelectorAll('.hm__name'));
var baseFormat = '기준 색상값 %h도';
// 배색 = 기준 색상값에 더할 각도 다섯 개. 칩 셋째 칸은 언제나 0 이라 기준 색 그대로다
var sets = { comp: [0, 0, 0, 180, 180], ana: [-60, -30, 0, 30, 60], tri: [-120, -120, 0, 120, 120] };
var hue = 262;
var current = 'comp';
// 글자와 색을 따로 쓰지 않는다 — 각도 다섯 개만 넘기면 hsl() 이 칩 색을, 계수기가 각도 글자를 찍는다
function apply() {
var offsets = sets[current];
for (var i = 0; i < offsets.length; i++) {
root.style.setProperty('--o' + (i + 1), offsets[i]);
}
root.style.setProperty('--base', hue);
base.setAttribute('aria-valuenow', hue);
base.setAttribute('aria-valuetext', baseFormat.replace('%h', hue));
tabs.forEach(function (btn, i) {
var on = btn.getAttribute('data-set') === current;
btn.setAttribute('aria-pressed', on ? 'true' : 'false');
names[i].classList.toggle('is-on', on);
});
}
tabs.forEach(function (btn) {
btn.addEventListener('click', function () {
current = btn.getAttribute('data-set');
root.classList.remove('is-demo');
apply();
});
});
base.addEventListener('keydown', function (e) {
var step = e.shiftKey ? 15 : 3;
if (e.key === 'ArrowRight') { hue = (hue + step) % 360; }
else if (e.key === 'ArrowLeft') { hue = (hue - step + 360) % 360; }
else { return; }
e.preventDefault();
root.classList.remove('is-demo');
apply();
});
apply();
})();// HarmonyGenerator — vanilla 판(run/286/demo/06-color-picker-harmony-generator) 의 React 포트.
// 변수 3개(duration·easing·color)는 props 로 받아 CSS 변수(var(--duration) 등)로 들어온다.
// 상태는 기준 색상값 하나와 고른 배색 이름 하나뿐이다. 칩 색과 각도 글자는 JS 가 쓰지 않는다 —
// "기준에 더할 각도" 다섯 개(--o1 ~ --o5)만 넘기면 hsl() 이 색을, counter-set 이 각도 숫자를 찍는다.
import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./HarmonyGenerator.module.scss";
type KeyEvt = { key: string; shiftKey: boolean; preventDefault(): void };
/** 배색 = 기준 색상값에 더할 각도 다섯 개. 셋째 칸은 언제나 0 이라 기준 색 그대로다 */
type HarmonyName = "comp" | "ana" | "tri";
export type HarmonyGeneratorProps = {
baseLabel?: string;
/** "기준 색상값 %h도" 처럼 자리표시자 하나를 가진 틀 */
baseFormat?: string;
groupLabel?: string;
/** 탭 라벨 셋 — 보색·유사색·삼각 */
tabLabels?: string[];
/** 배색 이름 셋 — 탭보다 긴 형태 */
setLabels?: string[];
/** 격자 미리보기용 2초 자동 루프. 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
"--base"?: number;
"--o1"?: number;
"--o2"?: number;
"--o3"?: number;
"--o4"?: number;
"--o5"?: number;
};
const SETS: Record<HarmonyName, number[]> = {
comp: [0, 0, 0, 180, 180],
ana: [-60, -30, 0, 30, 60],
tri: [-120, -120, 0, 120, 120],
};
const ORDER: HarmonyName[] = ["comp", "ana", "tri"];
export default function HarmonyGenerator({
baseLabel = "기준 색상값",
baseFormat = "기준 색상값 %h도",
groupLabel = "배색 고르기",
tabLabels = ["보색", "유사색", "삼각"],
setLabels = ["보색 배색", "유사색 배색", "삼각 배색"],
demo = true,
duration = 2000,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#2f6df6",
}: HarmonyGeneratorProps) {
const [live, setLive] = useState(demo);
const [hue, setHue] = useState(262);
const [current, setCurrent] = useState<HarmonyName>("comp");
const offsets = SETS[current];
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
"--base": hue,
"--o1": offsets[0],
"--o2": offsets[1],
"--o3": offsets[2],
"--o4": offsets[3],
"--o5": offsets[4],
};
function pick(name: HarmonyName) {
setLive(false);
setCurrent(name);
}
function keyBase(e: KeyEvt) {
const step = e.shiftKey ? 15 : 3;
if (e.key === "ArrowRight") setHue((hue + step) % 360);
else if (e.key === "ArrowLeft") setHue((hue - step + 360) % 360);
else return;
e.preventDefault();
setLive(false);
}
const baseText = baseFormat.replace("%h", String(hue));
return (
<div className={[styles.hm, live ? styles.isDemo : ""].filter(Boolean).join(" ")} style={vars}>
<div className={styles.top}>
<div
className={styles.base}
role="slider"
tabIndex={0}
aria-label={baseLabel}
aria-valuemin={0}
aria-valuemax={360}
aria-valuenow={hue}
aria-valuetext={baseText}
onKeyDown={keyBase}
/>
<span className={styles.basev} aria-hidden="true" />
<span className={styles.names} aria-hidden="true">
{setLabels.map((label, i) => (
<b
key={label}
className={[styles.name, styles[`name${i + 1}`], ORDER[i] === current ? styles.isOn : ""]
.filter(Boolean)
.join(" ")}
>
{label}
</b>
))}
</span>
</div>
<div className={styles.tabs} role="group" aria-label={groupLabel}>
{tabLabels.map((label, i) => (
<button
key={label}
type="button"
className={styles.tab}
aria-pressed={ORDER[i] === current}
onClick={() => pick(ORDER[i])}
>
{label}
</button>
))}
</div>
<ul className={styles.chips}>
{[0, 1, 2, 3, 4].map((i) => (
<li key={i} className={styles.chip}>
<span className={styles.sw} />
<span className={styles.deg} />
</li>
))}
</ul>
</div>
);
}06 · 색상값을 돌려 만드는 보색·유사색·삼각 배색 — 측정 누적면적 9.759% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
색을 다섯 번 계산하지 않고 칩마다 「기준에 더할 각도」 하나만 두어 hsl() 이 그 자리에서 만들게 하는 것이 핵심입니다.
// 칩 다섯 개 — 색도 각도 글자도 같은 --oN 하나에서 나온다
.hm__chip:nth-child(1) .hm__sw { background: hsl(calc(var(--base) + var(--o1)) 68% 70%); }
.hm__chip:nth-child(1) .hm__deg { counter-set: cd calc(var(--o1)); }
.hm__deg::after { content: counter(cd) "°"; }
// 보색(0 0 0 180 180) → 유사색(-60 -30 0 30 60) → 삼각(-120 -120 0 120 120).
// 배색 교체는 steps(1, end) 로 한 프레임에 컷한다 — 크로스페이드는 반투명 유령을 남긴다
@keyframes hmSet {
0% { --o1: 0; --o2: 0; --o3: 0; --o4: 180; --o5: 180; }
33% { --o1: -60; --o2: -30; --o3: 0; --o4: 30; --o5: 60; }
66% { --o1: -120; --o2: -120; --o3: 0; --o4: 120; --o5: 120; }
90%, 100% { --o1: 0; --o2: 0; --o3: 0; --o4: 180; --o5: 180; }
}
07두 색의 명암비를 재서 AA·AAA 를 붙이는 검사기
무엇이 보이나
이 사이트가 매 편 접근성 표를 채우는 바로 그 공식을 부품으로 꺼냈습니다. 글자색과 배경색의 상대 휘도를 각각 구해 비율을 내고, 4.5 와 7 을 넘는 순간 AA·AAA 배지가 켜집니다.

<main class="stage">
<div class="cc is-demo">
<div class="cc__prev">
<span class="cc__sample">본문 글자 견본</span>
<span class="cc__ratio" aria-hidden="true"></span>
</div>
<div class="cc__row">
<span class="cc__badge cc__badge--aa" aria-hidden="true">
<b class="cc__grade">AA</b>
<span class="cc__state"><i class="cc__pass">통과</i><i class="cc__fail">미달</i></span>
</span>
<span class="cc__badge cc__badge--aaa" aria-hidden="true">
<b class="cc__grade">AAA</b>
<span class="cc__state"><i class="cc__pass">통과</i><i class="cc__fail">미달</i></span>
</span>
<input class="cc__input" id="ccInput" type="color" value="#2f6df6" aria-label="배경색 직접 고르기">
</div>
<div class="cc__sws">
<button type="button" class="cc__sw" data-bg="#8aa9ff">2.28</button>
<button type="button" class="cc__sw" data-bg="#4d8bf0">3.35</button>
<button type="button" class="cc__sw" data-bg="#2f6df6">4.53</button>
<button type="button" class="cc__sw" data-bg="#1f4fb8">7.32</button>
<button type="button" class="cc__sw" data-bg="#17141a">18.24</button>
</div>
<span class="cc__live" aria-live="polite"></span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 대비 검사기 — 흰 글자 고정, 배경만 다섯 단계로 바뀐다.
// 배경색(--bg)·비율 정수부(--rw)·소수 두 자리(--rf)·배지 두 개(--aa --aaa)를 **같은 퍼센트에서 한꺼번에**
// steps(1, end) 로 컷한다. 다섯 값이 한 키프레임 안에 같이 적혀 있으니 배경과 숫자와 배지가 갈릴 수가 없다.
// 숫자 글자는 계수기가 찍는다 — 4.53 도 18.24 도 정수부·소수부 두 계수기의 조합이다.
// 컷만 쓰면 움직인 프레임이 두세 장이라, 컷 직후 배지가 자기 중심에서 튀어올라 사이를 메운다.
$duration: $dur-loop;
$easing: $ease-pop;
$color: $subject-blue;
@include stage($stage-ink);
// 다섯 단계의 배경. 실제 WCAG 상대 휘도로 계산해 둔 값이라 아래 비율 숫자와 짝이 맞는다
$bg-1: #8aa9ff; // 흰 글자 대비 2.28 — AA 미달
$bg-2: #4d8bf0; // 3.35 — AA 미달
$bg-3: $subject-blue; // 4.53 — AA 통과
$bg-4: #1f4fb8; // 7.32 — AAA 통과
$bg-5: $stage-ink; // 18.24 — 넉넉히 통과
@property --bg { syntax: "<color>"; inherits: true; initial-value: #{$bg-3}; }
@property --rw { syntax: "<integer>"; inherits: true; initial-value: 4; }
@property --rf { syntax: "<integer>"; inherits: true; initial-value: 53; }
@property --aa { syntax: "<number>"; inherits: true; initial-value: 1; }
@property --aaa { syntax: "<number>"; inherits: true; initial-value: 0; }
// 소수 두 자리를 언제나 두 칸으로 찍는 눈금. pad 2 "0" 이 1.05 의 05 를 지켜 준다
@counter-style dd {
system: numeric;
symbols: "0" "1" "2" "3" "4" "5" "6" "7" "8" "9";
pad: 2 "0";
}
.cc {
position: relative;
box-sizing: border-box;
width: 288px;
max-width: 100%;
padding: $sp-3;
border-radius: $r-card;
background: $subject-cream;
color: $subject-ink;
font-family: inherit;
}
.cc * { box-sizing: border-box; }
.cc__prev {
display: flex;
align-items: center;
justify-content: space-between;
gap: $sp-2;
height: 56px;
padding: 0 $sp-3;
border-radius: $r-sm;
background: var(--bg);
color: #ffffff;
}
.cc__sample {
font-size: 13px;
font-weight: 600;
line-height: 1.3;
}
.cc__ratio {
counter-set: cw calc(var(--rw)) cf calc(var(--rf));
white-space: nowrap;
font-size: 20px;
font-weight: 800;
line-height: 1.1;
letter-spacing: .02em;
}
.cc__ratio::after { content: counter(cw) "." counter(cf, dd) " : 1"; }
.cc__row {
display: flex;
align-items: center;
gap: $sp-2;
margin-top: $sp-2;
}
.cc__badge {
position: relative;
display: flex;
align-items: center;
gap: $sp-1;
padding: $sp-1 $sp-2;
border-radius: $r-pill;
background: rgba(23, 20, 26, .72);
color: #ffffff;
}
// 통과하면 배지 알맹이가 파랗게 찬다. 켜짐은 색만이 아니라 아래 통과·미달 글자로도 알린다
.cc__badge::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
background: $color;
}
.cc__badge--aa::before { opacity: var(--aa); }
.cc__badge--aaa::before { opacity: var(--aaa); }
.cc__grade {
position: relative;
z-index: 1;
font-size: 11px;
font-weight: 800;
line-height: 1.2;
}
// 미달일 때 등급 글자에 취소선이 그어진다. 왼쪽 변에서 자라므로 transform-origin 을 변에 둔다
.cc__grade::after {
content: "";
position: absolute;
left: 0;
right: 0;
top: 50%;
height: 1px;
background: currentColor;
transform-origin: left center;
}
.cc__badge--aa .cc__grade::after { transform: scaleX(calc(1 - var(--aa))); }
.cc__badge--aaa .cc__grade::after { transform: scaleX(calc(1 - var(--aaa))); }
.cc__state {
position: relative;
z-index: 1;
display: grid;
font-size: 11px;
font-weight: 700;
line-height: 1.2;
}
.cc__state > i {
grid-area: 1 / 1;
font-style: normal;
white-space: nowrap;
}
.cc__badge--aa .cc__pass { opacity: var(--aa); }
.cc__badge--aa .cc__fail { opacity: calc(1 - var(--aa)); }
.cc__badge--aaa .cc__pass { opacity: var(--aaa); }
.cc__badge--aaa .cc__fail { opacity: calc(1 - var(--aaa)); }
.cc__input {
margin-left: auto;
width: 32px;
height: 26px;
padding: 0;
border: 2px solid rgba(23, 20, 26, .16);
border-radius: $r-xs;
background: transparent;
cursor: pointer;
}
.cc__sws {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: $sp-2;
margin-top: $sp-2;
}
// 칩에도 그 배경의 명암비를 적어 둔다 — 색만으로 상태를 전달하지 않는다
.cc__sw {
width: 100%;
padding: $sp-1 0;
border: 0;
border-radius: $r-xs;
background: rgba(23, 20, 26, .08);
color: $subject-ink;
cursor: pointer;
font-family: inherit;
font-size: 9px;
font-weight: 700;
line-height: 1.2;
}
.cc__sw:focus-visible { outline: 3px solid $color; outline-offset: 2px; }
// 화면에는 안 보이되 보조기술에는 읽히는 알림 줄
.cc__live {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
// ── 미리보기 루프(정적 규칙 먼저, 그 위에 덧씌움) ─────────────────
.cc.is-demo {
animation-name: ccStep;
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
// 배지는 자기 중심에서 튄다 — transform-origin 기본값(50% 50%)
.cc.is-demo .cc__badge {
animation-name: ccPop;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.cc.is-demo .cc__badge--aaa { animation-delay: 80ms; }
// 다섯 단계를 0 · 18 · 36 · 54 · 72% 에 두고 90% 에 첫 단계로 돌아온다.
// 24프레임의 마지막 장이 95.8% 지점이라 복귀가 90% 를 넘으면 촬영되지 않는다
@keyframes ccStep {
0% { --bg: #{$bg-1}; --rw: 2; --rf: 28; --aa: 0; --aaa: 0; }
18% { --bg: #{$bg-2}; --rw: 3; --rf: 35; --aa: 0; --aaa: 0; }
36% { --bg: #{$bg-3}; --rw: 4; --rf: 53; --aa: 1; --aaa: 0; }
54% { --bg: #{$bg-4}; --rw: 7; --rf: 32; --aa: 1; --aaa: 1; }
72% { --bg: #{$bg-5}; --rw: 18; --rf: 24; --aa: 1; --aaa: 1; }
90%, 100% { --bg: #{$bg-1}; --rw: 2; --rf: 28; --aa: 0; --aaa: 0; }
}
@keyframes ccPop {
0% { transform: scale(.8); }
6% { transform: scale(1.06); }
11%, 16% { transform: scale(1); }
18% { transform: scale(.8); }
24% { transform: scale(1.06); }
29%, 34% { transform: scale(1); }
36% { transform: scale(.8); }
42% { transform: scale(1.06); }
47%, 52% { transform: scale(1); }
54% { transform: scale(.8); }
60% { transform: scale(1.06); }
65%, 70% { transform: scale(1); }
72% { transform: scale(.8); }
78% { transform: scale(1.06); }
83%, 88% { transform: scale(1); }
90% { transform: scale(.8); }
96% { transform: scale(1.04); }
100% { transform: scale(1); }
}
@media (prefers-reduced-motion: reduce) {
.cc.is-demo,
.cc.is-demo .cc__badge {
animation: none !important;
}
}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);
}
@property --bg {
syntax: "<color>";
inherits: true;
initial-value: #2f6df6;
}
@property --rw {
syntax: "<integer>";
inherits: true;
initial-value: 4;
}
@property --rf {
syntax: "<integer>";
inherits: true;
initial-value: 53;
}
@property --aa {
syntax: "<number>";
inherits: true;
initial-value: 1;
}
@property --aaa {
syntax: "<number>";
inherits: true;
initial-value: 0;
}
@counter-style dd {
system: numeric;
symbols: "0" "1" "2" "3" "4" "5" "6" "7" "8" "9";
pad: 2 "0";
}
.cc {
position: relative;
box-sizing: border-box;
width: 288px;
max-width: 100%;
padding: 12px;
border-radius: 16px;
background: #fff7e6;
color: #17141a;
font-family: inherit;
}
.cc * {
box-sizing: border-box;
}
.cc__prev {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
height: 56px;
padding: 0 12px;
border-radius: 8px;
background: var(--bg);
color: #ffffff;
}
.cc__sample {
font-size: 13px;
font-weight: 600;
line-height: 1.3;
}
.cc__ratio {
counter-set: cw calc(var(--rw)) cf calc(var(--rf));
white-space: nowrap;
font-size: 20px;
font-weight: 800;
line-height: 1.1;
letter-spacing: 0.02em;
}
.cc__ratio::after {
content: counter(cw) "." counter(cf, dd) " : 1";
}
.cc__row {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
}
.cc__badge {
position: relative;
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.72);
color: #ffffff;
}
.cc__badge::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
background: #2f6df6;
}
.cc__badge--aa::before {
opacity: var(--aa);
}
.cc__badge--aaa::before {
opacity: var(--aaa);
}
.cc__grade {
position: relative;
z-index: 1;
font-size: 11px;
font-weight: 800;
line-height: 1.2;
}
.cc__grade::after {
content: "";
position: absolute;
left: 0;
right: 0;
top: 50%;
height: 1px;
background: currentColor;
transform-origin: left center;
}
.cc__badge--aa .cc__grade::after {
transform: scaleX(calc(1 - var(--aa)));
}
.cc__badge--aaa .cc__grade::after {
transform: scaleX(calc(1 - var(--aaa)));
}
.cc__state {
position: relative;
z-index: 1;
display: grid;
font-size: 11px;
font-weight: 700;
line-height: 1.2;
}
.cc__state > i {
grid-area: 1/1;
font-style: normal;
white-space: nowrap;
}
.cc__badge--aa .cc__pass {
opacity: var(--aa);
}
.cc__badge--aa .cc__fail {
opacity: calc(1 - var(--aa));
}
.cc__badge--aaa .cc__pass {
opacity: var(--aaa);
}
.cc__badge--aaa .cc__fail {
opacity: calc(1 - var(--aaa));
}
.cc__input {
margin-left: auto;
width: 32px;
height: 26px;
padding: 0;
border: 2px solid rgba(23, 20, 26, 0.16);
border-radius: 4px;
background: transparent;
cursor: pointer;
}
.cc__sws {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 8px;
margin-top: 8px;
}
.cc__sw {
width: 100%;
padding: 4px 0;
border: 0;
border-radius: 4px;
background: rgba(23, 20, 26, 0.08);
color: #17141a;
cursor: pointer;
font-family: inherit;
font-size: 9px;
font-weight: 700;
line-height: 1.2;
}
.cc__sw:focus-visible {
outline: 3px solid #2f6df6;
outline-offset: 2px;
}
.cc__live {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.cc.is-demo {
animation-name: ccStep;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.cc.is-demo .cc__badge {
animation-name: ccPop;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.cc.is-demo .cc__badge--aaa {
animation-delay: 80ms;
}
@keyframes ccStep {
0% {
--bg: #8aa9ff;
--rw: 2;
--rf: 28;
--aa: 0;
--aaa: 0;
}
18% {
--bg: #4d8bf0;
--rw: 3;
--rf: 35;
--aa: 0;
--aaa: 0;
}
36% {
--bg: #2f6df6;
--rw: 4;
--rf: 53;
--aa: 1;
--aaa: 0;
}
54% {
--bg: #1f4fb8;
--rw: 7;
--rf: 32;
--aa: 1;
--aaa: 1;
}
72% {
--bg: #17141a;
--rw: 18;
--rf: 24;
--aa: 1;
--aaa: 1;
}
90%, 100% {
--bg: #8aa9ff;
--rw: 2;
--rf: 28;
--aa: 0;
--aaa: 0;
}
}
@keyframes ccPop {
0% {
transform: scale(0.8);
}
6% {
transform: scale(1.06);
}
11%, 16% {
transform: scale(1);
}
18% {
transform: scale(0.8);
}
24% {
transform: scale(1.06);
}
29%, 34% {
transform: scale(1);
}
36% {
transform: scale(0.8);
}
42% {
transform: scale(1.06);
}
47%, 52% {
transform: scale(1);
}
54% {
transform: scale(0.8);
}
60% {
transform: scale(1.06);
}
65%, 70% {
transform: scale(1);
}
72% {
transform: scale(0.8);
}
78% {
transform: scale(1.06);
}
83%, 88% {
transform: scale(1);
}
90% {
transform: scale(0.8);
}
96% {
transform: scale(1.04);
}
100% {
transform: scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.cc.is-demo,
.cc.is-demo .cc__badge {
animation: none !important;
}
}(function () {
var root = document.querySelector('.cc');
var input = document.getElementById('ccInput');
var live = root.querySelector('.cc__live');
var liveFormat = '명암비 %r 대 1, AA %a, AAA %b';
var passWord = '통과';
var failWord = '미달';
// WCAG 2.x 상대 휘도 — 채널마다 감마를 되돌린 뒤 사람 눈의 민감도로 가중한다
function channel(c) { return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); }
function luminance(hex) {
var n = parseInt(hex.slice(1), 16);
var r = channel(((n >> 16) & 255) / 255);
var g = channel(((n >> 8) & 255) / 255);
var b = channel((n & 255) / 255);
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
// 글자는 흰색 고정이므로 밝은 쪽 휘도는 언제나 1 이다
function ratioToWhite(hex) { return 1.05 / (luminance(hex) + 0.05); }
function ratioToInk(hex) { return (luminance(hex) + 0.05) / (luminance('#17141a') + 0.05); }
// 숫자 글자는 JS 가 쓰지 않는다 — 정수부와 소수 두 자리만 넘기면 계수기가 4.53 꼴로 찍는다
function setBg(hex) {
var ratio = ratioToWhite(hex);
var whole = Math.floor(ratio);
var frac = Math.round((ratio - whole) * 100);
if (frac === 100) { whole += 1; frac = 0; }
root.style.setProperty('--bg', hex);
root.style.setProperty('--rw', whole);
root.style.setProperty('--rf', frac);
root.style.setProperty('--aa', ratio >= 4.5 ? 1 : 0);
root.style.setProperty('--aaa', ratio >= 7 ? 1 : 0);
live.textContent = liveFormat
.replace('%r', whole + '.' + (frac < 10 ? '0' + frac : String(frac)))
.replace('%a', ratio >= 4.5 ? passWord : failWord)
.replace('%b', ratio >= 7 ? passWord : failWord);
}
[].slice.call(root.querySelectorAll('.cc__sw')).forEach(function (btn) {
var hex = btn.getAttribute('data-bg');
btn.style.background = hex;
btn.style.color = ratioToWhite(hex) >= ratioToInk(hex) ? '#ffffff' : '#17141a';
btn.addEventListener('click', function () {
root.classList.remove('is-demo');
input.value = hex;
setBg(hex);
});
});
input.addEventListener('input', function () {
root.classList.remove('is-demo');
setBg(input.value);
});
setBg(input.value);
})();// ContrastChecker — vanilla 판(run/286/demo/07-color-picker-contrast-checker) 의 React 포트.
// 변수 3개(duration·easing·color)는 props 로 받아 CSS 변수(var(--duration) 등)로 들어온다.
// 상태는 배경색 hex 문자열 하나뿐이다. 비율 숫자는 JS 가 글자로 쓰지 않는다 —
// 정수부(--rw)와 소수 두 자리(--rf)만 넘기면 module.scss 의 counter-set 이 4.53 꼴로 찍는다.
import { useState } from "react";
import type { CSSProperties, ChangeEvent } from "react";
import styles from "./ContrastChecker.module.scss";
export type ContrastCheckerProps = {
sampleText?: string;
passWord?: string;
failWord?: string;
inputLabel?: string;
/** "명암비 %r 대 1, AA %a, AAA %b" 처럼 자리표시자 셋을 가진 틀 */
liveFormat?: string;
/** 격자 미리보기용 2초 자동 루프. 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
"--bg"?: string;
"--rw"?: number;
"--rf"?: number;
"--aa"?: number;
"--aaa"?: number;
};
const SWATCHES = ["#8aa9ff", "#4d8bf0", "#2f6df6", "#1f4fb8", "#17141a"];
const INK = "#17141a";
// WCAG 2.x 상대 휘도 — 채널마다 감마를 되돌린 뒤 사람 눈의 민감도로 가중한다
function channel(c: number): number {
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
}
function luminance(hex: string): number {
const n = parseInt(hex.slice(1), 16);
const r = channel(((n >> 16) & 255) / 255);
const g = channel(((n >> 8) & 255) / 255);
const b = channel((n & 255) / 255);
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
// 글자는 흰색 고정이므로 밝은 쪽 휘도는 언제나 1 이다
function ratioToWhite(hex: string): number {
return 1.05 / (luminance(hex) + 0.05);
}
function ratioToInk(hex: string): number {
return (luminance(hex) + 0.05) / (luminance(INK) + 0.05);
}
function twoDigits(n: number): string {
return n < 10 ? `0${n}` : String(n);
}
export default function ContrastChecker({
sampleText = "본문 글자 견본",
passWord = "통과",
failWord = "미달",
inputLabel = "배경색 직접 고르기",
liveFormat = "명암비 %r 대 1, AA %a, AAA %b",
demo = true,
duration = 2000,
easing = "cubic-bezier(.2, 1.4, .4, 1)",
color = "#2f6df6",
}: ContrastCheckerProps) {
const [live, setLive] = useState(demo);
const [bg, setBg] = useState("#2f6df6");
const ratio = ratioToWhite(bg);
let whole = Math.floor(ratio);
let frac = Math.round((ratio - whole) * 100);
if (frac === 100) {
whole += 1;
frac = 0;
}
const passesAa = ratio >= 4.5;
const passesAaa = ratio >= 7;
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
"--bg": bg,
"--rw": whole,
"--rf": frac,
"--aa": passesAa ? 1 : 0,
"--aaa": passesAaa ? 1 : 0,
};
const liveText = liveFormat
.replace("%r", `${whole}.${twoDigits(frac)}`)
.replace("%a", passesAa ? passWord : failWord)
.replace("%b", passesAaa ? passWord : failWord);
function pick(hex: string) {
setLive(false);
setBg(hex);
}
function onInput(e: ChangeEvent<HTMLInputElement>) {
setLive(false);
setBg(e.target.value);
}
function badge(kind: "aa" | "aaa", grade: string) {
return (
<span
className={[styles.badge, kind === "aa" ? styles.badgeAa : styles.badgeAaa].join(" ")}
aria-hidden="true"
>
<b className={styles.grade}>{grade}</b>
<span className={styles.state}>
<i className={styles.pass}>{passWord}</i>
<i className={styles.fail}>{failWord}</i>
</span>
</span>
);
}
return (
<div className={[styles.cc, live ? styles.isDemo : ""].filter(Boolean).join(" ")} style={vars}>
<div className={styles.prev}>
<span className={styles.sample}>{sampleText}</span>
<span className={styles.ratio} aria-hidden="true" />
</div>
<div className={styles.row}>
{badge("aa", "AA")}
{badge("aaa", "AAA")}
<input
className={styles.input}
type="color"
value={bg}
aria-label={inputLabel}
onChange={onInput}
/>
</div>
<div className={styles.sws}>
{SWATCHES.map((hex) => (
<button
key={hex}
type="button"
className={styles.sw}
style={{ background: hex, color: ratioToWhite(hex) >= ratioToInk(hex) ? "#ffffff" : INK }}
onClick={() => pick(hex)}
>
{`${Math.floor(ratioToWhite(hex))}.${twoDigits(Math.round((ratioToWhite(hex) % 1) * 100))}`}
</button>
))}
</div>
<span className={styles.liveRegion} aria-live="polite">
{liveText}
</span>
</div>
);
}07 · 두 색의 명암비를 재서 AA·AAA 를 붙이는 검사기 — 측정 누적면적 12.05% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
다섯 단계 배경을 흰 글자로 재는 미리보기는 누적 변화 면적 12.05% 로 아홉 벌 중 두 번째로 넓게 움직입니다.
// WCAG 2.x 상대 휘도 — 채널마다 감마를 되돌린 뒤 사람 눈의 민감도로 가중한다
function channel(c) { return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); }
function luminance(hex) {
var n = parseInt(hex.slice(1), 16);
return 0.2126 * channel(((n >> 16) & 255) / 255)
+ 0.7152 * channel(((n >> 8) & 255) / 255)
+ 0.0722 * channel((n & 255) / 255);
}
// 글자는 흰색 고정이므로 밝은 쪽 휘도는 언제나 1 이다
function ratioToWhite(hex) { return 1.05 / (luminance(hex) + 0.05); }
// 비율 숫자 — 정수부와 소수 두 자리를 따로 찍어 4.53 꼴을 만든다
@counter-style dd { system: numeric; symbols: "0" "1" "2" "3" "4" "5" "6" "7" "8" "9"; pad: 2 "0"; }
.cc__ratio { counter-set: cw calc(var(--rw)) cf calc(var(--rf)); }
.cc__ratio::after { content: counter(cw) "." counter(cf, dd) " : 1"; }
08정지점을 끌어 만드는 그라디언트 막대
화면에서 일어나는 일
히어로 배경에 쓸 그라디언트를 눈으로 맞추는 빌더입니다. 막대 위 정지점을 끌면 위치가 퍼센트로 읽히고, 그 아래에 linear-gradient 문자열이 그대로 나와 복사해 쓰면 됩니다.

<main class="stage">
<div class="gs is-demo">
<div class="gs__bar" id="gsBar">
<span class="gs__stop gs__stop--a" aria-hidden="true"></span>
<span class="gs__stop gs__stop--m" id="gsMid" role="slider" tabindex="0"
aria-label="가운데 정지점 위치"
aria-valuemin="0" aria-valuemax="100" aria-valuenow="45"
aria-valuetext="가운데 정지점 45%"></span>
<span class="gs__stop gs__stop--b" aria-hidden="true"></span>
</div>
<code class="gs__code" aria-hidden="true"></code>
<div class="gs__foot">
<span class="gs__pos" aria-hidden="true">가운데 <b class="gs__pct"></b></span>
<button type="button" class="gs__copy" id="gsCopy" aria-label="CSS 문자열 복사">
<svg viewBox="0 0 256 256" fill="currentColor" aria-hidden="true"><path d="M216,40V168H168V88H88V40Z" opacity="0.2"/><path d="M216,32H88a8,8,0,0,0-8,8V80H40a8,8,0,0,0-8,8V216a8,8,0,0,0,8,8H168a8,8,0,0,0,8-8V176h40a8,8,0,0,0,8-8V40A8,8,0,0,0,216,32ZM160,208H48V96H160Zm48-48H176V88a8,8,0,0,0-8-8H96V48H208Z"/></svg>
</button>
</div>
<span class="gs__live" aria-live="polite"></span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 그라디언트 빌더 — 움직이는 값은 가운데 정지점 위치 --p 하나뿐이다.
// 막대의 색 분포(linear-gradient 의 둘째 정지점), 손잡이 자리(translateX), 아래 CSS 문자열, 퍼센트 숫자가
// 전부 그 한 값에서 나온다. 값이 하나니 넷이 어긋날 수가 없다.
// 264px 트랙에서 1% = 2.64px 이라 손잡이는 translateX(calc(var(--p) * 2.64px)) 로 그 자리에 선다.
// 루프는 45% → 78% → 같은 길을 되짚어 45% 로 돌아온다(텔레포트 없음).
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-yellow);
$track: 264px;
@property --p { syntax: "<integer>"; inherits: true; initial-value: 45; }
.gs {
position: relative;
box-sizing: border-box;
width: 288px;
max-width: 100%;
padding: $sp-3;
border-radius: $r-card;
background: $subject-cream;
color: $subject-ink;
font-family: inherit;
}
.gs * { box-sizing: border-box; }
.gs__bar {
position: relative;
height: 36px;
border-radius: $r-sm;
cursor: pointer;
touch-action: none;
background-image: linear-gradient(90deg, $stage-orange 0%, $stage-yellow calc(var(--p) * 1%), $subject-blue 100%);
}
.gs__stop {
position: absolute;
top: 50%;
left: 0;
width: 16px;
height: 16px;
border-radius: 50%;
border: 3px solid #ffffff;
box-shadow: $shadow-press;
}
// 손잡이는 자기 중심이 그 퍼센트 위에 놓인다 — 좌표 이동 뒤 자기 크기의 절반만큼 되민다
.gs__stop--a {
background: $stage-orange;
transform: translateX(0) translate(-50%, -50%);
}
.gs__stop--m {
background: $stage-yellow;
cursor: ew-resize;
touch-action: none;
transform: translateX(calc(var(--p) * 2.64px)) translate(-50%, -50%);
}
.gs__stop--b {
background: $subject-blue;
transform: translateX($track) translate(-50%, -50%);
}
.gs__stop--m:focus-visible { outline: 3px solid $color; outline-offset: 3px; }
// 아래 CSS 문자열 — 퍼센트 자리만 계수기가 채운다. 색 이름은 sass 가 토큰 값을 그대로 박아 넣는다
.gs__code {
display: block;
margin-top: $sp-2;
counter-set: cp calc(var(--p));
word-break: break-all;
font-size: 10px;
font-weight: 500;
line-height: 1.4;
color: rgba(23, 20, 26, .74);
}
.gs__code::after {
content: "linear-gradient(90deg, #{$stage-orange} 0%, #{$stage-yellow} " counter(cp) "%, #{$subject-blue} 100%)";
}
.gs__foot {
display: flex;
align-items: center;
gap: $sp-2;
margin-top: $sp-2;
}
.gs__pos {
font-size: 11px;
font-weight: 700;
line-height: 1.2;
}
.gs__pct { counter-set: cp calc(var(--p)); font-weight: 800; }
.gs__pct::after { content: counter(cp) "%"; }
.gs__copy {
margin-left: auto;
display: grid;
place-items: center;
width: 28px;
height: 28px;
padding: 0;
border: 2px solid rgba(23, 20, 26, .16);
border-radius: $r-control;
background: transparent;
color: inherit;
cursor: pointer;
}
.gs__copy svg { width: 16px; height: 16px; }
.gs__copy:focus-visible { outline: 3px solid $color; outline-offset: 2px; }
// 화면에는 안 보이되 보조기술에는 읽히는 알림 줄
.gs__live {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
// ── 미리보기 루프(정적 규칙 먼저, 그 위에 덧씌움) ─────────────────
// 애니메이션하는 것은 --p 하나뿐이다. 막대·손잡이·문자열·숫자가 같은 값을 읽으니
// 어느 프레임에서도 넷이 같은 순간을 가리킨다
.gs.is-demo {
animation-name: gsSlide;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes gsSlide {
0% { --p: 45; }
45% { --p: 78; }
90%, 100% { --p: 45; }
}
@media (prefers-reduced-motion: reduce) {
.gs.is-demo {
animation: none !important;
}
}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);
}
@property --p {
syntax: "<integer>";
inherits: true;
initial-value: 45;
}
.gs {
position: relative;
box-sizing: border-box;
width: 288px;
max-width: 100%;
padding: 12px;
border-radius: 16px;
background: #fff7e6;
color: #17141a;
font-family: inherit;
}
.gs * {
box-sizing: border-box;
}
.gs__bar {
position: relative;
height: 36px;
border-radius: 8px;
cursor: pointer;
touch-action: none;
background-image: linear-gradient(90deg, #ff4d1f 0%, #ffd23f calc(var(--p) * 1%), #2f6df6 100%);
}
.gs__stop {
position: absolute;
top: 50%;
left: 0;
width: 16px;
height: 16px;
border-radius: 50%;
border: 3px solid #ffffff;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.gs__stop--a {
background: #ff4d1f;
transform: translateX(0) translate(-50%, -50%);
}
.gs__stop--m {
background: #ffd23f;
cursor: ew-resize;
touch-action: none;
transform: translateX(calc(var(--p) * 2.64px)) translate(-50%, -50%);
}
.gs__stop--b {
background: #2f6df6;
transform: translateX(264px) translate(-50%, -50%);
}
.gs__stop--m:focus-visible {
outline: 3px solid #2f6df6;
outline-offset: 3px;
}
.gs__code {
display: block;
margin-top: 8px;
counter-set: cp calc(var(--p));
word-break: break-all;
font-size: 10px;
font-weight: 500;
line-height: 1.4;
color: rgba(23, 20, 26, 0.74);
}
.gs__code::after {
content: "linear-gradient(90deg, #ff4d1f 0%, #ffd23f " counter(cp) "%, #2f6df6 100%)";
}
.gs__foot {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
}
.gs__pos {
font-size: 11px;
font-weight: 700;
line-height: 1.2;
}
.gs__pct {
counter-set: cp calc(var(--p));
font-weight: 800;
}
.gs__pct::after {
content: counter(cp) "%";
}
.gs__copy {
margin-left: auto;
display: grid;
place-items: center;
width: 28px;
height: 28px;
padding: 0;
border: 2px solid rgba(23, 20, 26, 0.16);
border-radius: 12px;
background: transparent;
color: inherit;
cursor: pointer;
}
.gs__copy svg {
width: 16px;
height: 16px;
}
.gs__copy:focus-visible {
outline: 3px solid #2f6df6;
outline-offset: 2px;
}
.gs__live {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.gs.is-demo {
animation-name: gsSlide;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes gsSlide {
0% {
--p: 45;
}
45% {
--p: 78;
}
90%, 100% {
--p: 45;
}
}
@media (prefers-reduced-motion: reduce) {
.gs.is-demo {
animation: none !important;
}
}(function () {
var root = document.querySelector('.gs');
var bar = document.getElementById('gsBar');
var mid = document.getElementById('gsMid');
var copyBtn = document.getElementById('gsCopy');
var live = root.querySelector('.gs__live');
var midFormat = '가운데 정지점 %p%';
var copiedText = '복사했습니다';
var pos = 45;
// 화면에 보이는 문자열과 복사되는 문자열은 같은 pos 하나에서 나온다.
// 막대 배경·손잡이 자리·아래 글자는 CSS 가 --p 에서 만들고, 복사본만 여기서 조립한다
function cssText() {
return 'linear-gradient(90deg, #ff4d1f 0%, #ffd23f ' + pos + '%, #2f6df6 100%)';
}
function apply() {
root.style.setProperty('--p', pos);
mid.setAttribute('aria-valuenow', pos);
mid.setAttribute('aria-valuetext', midFormat.replace('%p', pos));
}
function pickAt(e) {
var box = bar.getBoundingClientRect();
pos = Math.round(Math.min(100, Math.max(0, (e.clientX - box.left) / box.width * 100)));
apply();
}
bar.addEventListener('pointerdown', function (e) {
root.classList.remove('is-demo');
bar.setPointerCapture(e.pointerId); // 막대 밖으로 끌어도 이 요소가 계속 포인터를 받는다
pickAt(e);
});
bar.addEventListener('pointermove', function (e) {
if (bar.hasPointerCapture(e.pointerId)) { pickAt(e); }
});
mid.addEventListener('keydown', function (e) {
var step = e.shiftKey ? 10 : 1;
if (e.key === 'ArrowRight') { pos = Math.min(100, pos + step); }
else if (e.key === 'ArrowLeft') { pos = Math.max(0, pos - step); }
else { return; }
e.preventDefault();
root.classList.remove('is-demo');
apply();
});
copyBtn.addEventListener('click', function () {
root.classList.remove('is-demo');
if (navigator.clipboard) { navigator.clipboard.writeText(cssText()); }
live.textContent = copiedText;
});
apply();
})();// GradientStops — vanilla 판(run/286/demo/08-color-picker-gradient-stops) 의 React 포트.
// 변수 3개(duration·easing·color)는 props 로 받아 CSS 변수(var(--duration) 등)로 들어온다.
// 상태는 가운데 정지점 위치 하나(0~100)뿐이다. 막대의 색 분포도, 손잡이 자리도, 아래 CSS 문자열도,
// 퍼센트 숫자도 전부 그 한 값(--p)에서 나오므로 넷이 어긋날 수가 없다.
import { useRef, useState } from "react";
import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react";
import styles from "./GradientStops.module.scss";
type KeyEvt = { key: string; shiftKey: boolean; preventDefault(): void };
export type GradientStopsProps = {
midLabel?: string;
/** "가운데 정지점 %p%" 처럼 자리표시자 하나를 가진 틀 */
midFormat?: string;
posLabel?: string;
copyLabel?: string;
copiedText?: string;
/** 격자 미리보기용 2초 자동 루프. 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
"--p"?: number;
};
function clampPercent(n: number): number {
return Math.round(Math.min(100, Math.max(0, n)));
}
export default function GradientStops({
midLabel = "가운데 정지점 위치",
midFormat = "가운데 정지점 %p%",
posLabel = "가운데",
copyLabel = "CSS 문자열 복사",
copiedText = "복사했습니다",
demo = true,
duration = 2000,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#2f6df6",
}: GradientStopsProps) {
const [live, setLive] = useState(demo);
const [pos, setPos] = useState(45);
const [notice, setNotice] = useState("");
const barRef = useRef<HTMLDivElement>(null);
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
"--p": pos,
};
// 화면에 보이는 문자열과 복사되는 문자열은 같은 pos 하나에서 나온다
function cssText(): string {
return `linear-gradient(90deg, #ff4d1f 0%, #ffd23f ${pos}%, #2f6df6 100%)`;
}
function pickAt(e: ReactPointerEvent<HTMLDivElement>) {
const node = barRef.current;
if (!node) return;
const box = node.getBoundingClientRect();
setPos(clampPercent(((e.clientX - box.left) / box.width) * 100));
}
function start(e: ReactPointerEvent<HTMLDivElement>) {
setLive(false);
// 막대 밖으로 끌어도 이 요소가 계속 포인터를 받는다
e.currentTarget.setPointerCapture(e.pointerId);
pickAt(e);
}
function drag(e: ReactPointerEvent<HTMLDivElement>) {
if (e.currentTarget.hasPointerCapture(e.pointerId)) pickAt(e);
}
function keyMid(e: KeyEvt) {
const step = e.shiftKey ? 10 : 1;
if (e.key === "ArrowRight") setPos(Math.min(100, pos + step));
else if (e.key === "ArrowLeft") setPos(Math.max(0, pos - step));
else return;
e.preventDefault();
setLive(false);
}
function copy() {
setLive(false);
if (navigator.clipboard) navigator.clipboard.writeText(cssText());
setNotice(copiedText);
}
return (
<div className={[styles.gs, live ? styles.isDemo : ""].filter(Boolean).join(" ")} style={vars}>
<div ref={barRef} className={styles.bar} onPointerDown={start} onPointerMove={drag}>
<span className={[styles.stop, styles.stopA].join(" ")} aria-hidden="true" />
<span
className={[styles.stop, styles.stopM].join(" ")}
role="slider"
tabIndex={0}
aria-label={midLabel}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={pos}
aria-valuetext={midFormat.replace("%p", String(pos))}
onKeyDown={keyMid}
/>
<span className={[styles.stop, styles.stopB].join(" ")} aria-hidden="true" />
</div>
<code className={styles.code} aria-hidden="true" />
<div className={styles.foot}>
<span className={styles.pos} aria-hidden="true">
{posLabel} <b className={styles.pct} />
</span>
<button type="button" className={styles.copy} aria-label={copyLabel} onClick={copy}>
<svg viewBox="0 0 256 256" fill="currentColor" aria-hidden="true">
<path d="M216,40V168H168V88H88V40Z" opacity="0.2" />
<path d="M216,32H88a8,8,0,0,0-8,8V80H40a8,8,0,0,0-8,8V216a8,8,0,0,0,8,8H168a8,8,0,0,0,8-8V176h40a8,8,0,0,0,8-8V40A8,8,0,0,0,216,32ZM160,208H48V96H160Zm48-48H176V88a8,8,0,0,0-8-8H96V48H208Z" />
</svg>
</button>
</div>
<span className={styles.liveRegion} aria-live="polite">
{notice}
</span>
</div>
);
}08 · 정지점을 끌어 만드는 그라디언트 막대 — 측정 누적면적 5.947% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
색 분포와 손잡이 자리와 문자열이 --p 하나에서 함께 움직입니다.
// 아래 CSS 문자열 — 퍼센트 자리만 계수기가 채운다
.gs__bar {
background-image: linear-gradient(90deg, $stage-orange 0%, $stage-yellow calc(var(--p) * 1%), $subject-blue 100%);
}
.gs__stop--m { transform: translateX(calc(var(--p) * 2.64px)) translate(-50%, -50%); }
.gs__code { counter-set: cp calc(var(--p)); }
.gs__code::after {
content: "linear-gradient(90deg, #{$stage-orange} 0%, #{$stage-yellow} " counter(cp) "%, #{$subject-blue} 100%)";
}
09그림 위 픽셀을 집어 오는 스포이드
어떤 움직임인가
사진이나 스크린샷에서 색을 뽑는 축입니다. 캔버스에 그린 그림 위를 지나가면 그 자리 픽셀 한 개를 getImageData 로 읽어 확대경과 HEX 에 그대로 붙입니다.

<main class="stage">
<div class="ed is-demo">
<div class="ed__box" id="edBox" role="slider" tabindex="0"
aria-label="그림 위에서 색 집기"
aria-valuemin="0" aria-valuemax="264" aria-valuenow="40"
aria-valuetext="가로 40픽셀 지점, #FF7528">
<canvas class="ed__canvas" id="edCanvas" width="264" height="88"
role="img" aria-label="주황에서 노랑을 지나 파랑으로 가는 색 띠"></canvas>
<span class="ed__loupe" id="edLoupe" aria-hidden="true"></span>
</div>
<div class="ed__foot">
<span class="ed__chip" aria-hidden="true"></span>
<span class="ed__hex" aria-hidden="true"></span>
<button type="button" class="ed__pick" id="edPick">
<svg viewBox="0 0 256 256" fill="currentColor" aria-hidden="true"><path d="M207.8,87.6l-25.37,25.53,4.89,4.88a16,16,0,0,1,0,22.64l-9,9a8,8,0,0,1-11.32,0l-60.68-60.7a8,8,0,0,1,0-11.32l9-9a16,16,0,0,1,22.63,0l4.88,4.89,25-25.11c10.79-10.79,28.37-11.45,39.45-1A28,28,0,0,1,207.8,87.6Z" opacity="0.2"/><path d="M224,67.3a35.79,35.79,0,0,0-11.26-25.66c-14-13.28-36.72-12.78-50.62,1.13L142.8,62.2a24,24,0,0,0-33.14.77l-9,9a16,16,0,0,0,0,22.64l2,2.06-51,51a39.75,39.75,0,0,0-10.53,38l-8,18.41A13.68,13.68,0,0,0,36,219.3a15.92,15.92,0,0,0,17.71,3.35L71.23,215a39.89,39.89,0,0,0,37.06-10.75l51-51,2.06,2.06a16,16,0,0,0,22.62,0l9-9a24,24,0,0,0,.74-33.18l19.75-19.87A35.75,35.75,0,0,0,224,67.3ZM97,193a24,24,0,0,1-24,6,8,8,0,0,0-5.55.31l-18.1,7.91L57,189.41a8,8,0,0,0,.25-5.75A23.88,23.88,0,0,1,63,159l51-51,33.94,34ZM202.13,82l-25.37,25.52a8,8,0,0,0,0,11.3l4.89,4.89a8,8,0,0,1,0,11.32l-9,9L112,83.26l9-9a8,8,0,0,1,11.31,0l4.89,4.89a8,8,0,0,0,5.65,2.34h0a8,8,0,0,0,5.66-2.36l24.94-25.09c7.81-7.82,20.5-8.18,28.29-.81a20,20,0,0,1,.39,28.7Z"/></svg>
<span>화면에서 집기</span>
</button>
</div>
<span class="ed__live" aria-live="polite"></span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 스포이드 — 캔버스에 그린 그림에서 한 점의 RGBA 를 getImageData 로 읽어 온다.
// 확대경 색도, 아래 칩도, HEX 글자도 전부 --r --g --b 세 값에서 나온다(계수기가 16진으로 찍는다).
// 미리보기 루프는 확대경의 자리와 세 값을 **같은 퍼센트·같은 linear 이징**으로 돌린다.
// 캔버스 그라디언트는 채널마다 1차 보간이므로, 위치를 선형으로 옮기면 색도 선형으로 따라온다 —
// 그래서 어느 프레임에서도 확대경 색이 그 자리 픽셀과 정확히 같다.
$duration: $dur-loop;
$easing: linear;
$color: $subject-blue;
@include stage($stage-orange);
@property --r { syntax: "<integer>"; inherits: true; initial-value: 255; }
@property --g { syntax: "<integer>"; inherits: true; initial-value: 117; }
@property --b { syntax: "<integer>"; inherits: true; initial-value: 40; }
// 0~15 를 16진 한 자리로 찍는 계수기 눈금. pad 2 "0" 이 0F 처럼 앞자리를 채운다
@counter-style hx {
system: numeric;
symbols: "0" "1" "2" "3" "4" "5" "6" "7" "8" "9" "A" "B" "C" "D" "E" "F";
pad: 2 "0";
}
.ed {
position: relative;
box-sizing: border-box;
width: 288px;
max-width: 100%;
padding: $sp-3;
border-radius: $r-card;
background: $subject-cream;
color: $subject-ink;
font-family: inherit;
}
.ed * { box-sizing: border-box; }
.ed__box { position: relative; }
.ed__box:focus-visible { outline: 3px solid $color; outline-offset: 3px; }
.ed__canvas {
display: block;
width: 264px;
max-width: 100%;
height: 88px;
border-radius: $r-sm;
cursor: crosshair;
touch-action: none;
}
// 확대경은 자기 중심이 집은 점 위에 놓인다 — 좌표 이동 뒤 자기 크기의 절반만큼 되민다
.ed__loupe {
position: absolute;
left: 0;
top: 0;
width: 36px;
height: 36px;
border-radius: 50%;
border: 3px solid #ffffff;
box-shadow: $shadow-raised;
background: rgb(var(--r) var(--g) var(--b));
pointer-events: none;
transform: translate(40px, 28px) translate(-50%, -50%);
}
.ed__loupe::after {
content: "";
position: absolute;
left: 50%;
top: 50%;
width: 8px;
height: 8px;
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, .92);
transform: translate(-50%, -50%);
}
.ed__foot {
display: flex;
align-items: center;
gap: $sp-2;
margin-top: $sp-2;
}
.ed__chip {
width: 24px;
height: 24px;
border-radius: $r-xs;
background: rgb(var(--r) var(--g) var(--b));
}
// HEX 글자 — 값은 --r --g --b 뿐이고 계수기가 표기만 맡는다
.ed__hex {
counter-set: cr calc(var(--r)) cg calc(var(--g)) cb calc(var(--b));
font-size: 14px;
font-weight: 700;
line-height: 1.2;
letter-spacing: .04em;
}
.ed__hex::after { content: "#" counter(cr, hx) counter(cg, hx) counter(cb, hx); }
.ed__pick {
margin-left: auto;
display: flex;
align-items: center;
gap: $sp-1;
padding: $sp-1 $sp-2;
border: 2px solid rgba(23, 20, 26, .16);
border-radius: $r-pill;
background: transparent;
color: inherit;
cursor: pointer;
font-family: inherit;
font-size: 11px;
font-weight: 700;
line-height: 1.2;
}
.ed__pick svg { flex: 0 0 auto; width: 14px; height: 14px; }
.ed__pick:focus-visible { outline: 3px solid $color; outline-offset: 2px; }
// 화면에는 안 보이되 보조기술에는 읽히는 알림 줄
.ed__live {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
// ── 미리보기 루프(정적 규칙 먼저, 그 위에 덧씌움) ─────────────────
// 두 애니메이션의 퍼센트와 이징이 같아야 확대경 색과 그 자리 픽셀이 어긋나지 않는다.
// x = 40 은 띠의 주황 쪽, x = 132 는 가운데 정지점(노랑), x = 224 는 파랑 쪽이다
.ed.is-demo .ed__loupe {
animation-name: edMove;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.ed.is-demo {
animation-name: edColor;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes edMove {
0% { transform: translate(40px, 28px) translate(-50%, -50%); }
22.5% { transform: translate(132px, 28px) translate(-50%, -50%); }
45%, 55% { transform: translate(224px, 28px) translate(-50%, -50%); }
72.5% { transform: translate(132px, 28px) translate(-50%, -50%); }
90%, 100% { transform: translate(40px, 28px) translate(-50%, -50%); }
}
@keyframes edColor {
0% { --r: 255; --g: 117; --b: 40; }
22.5% { --r: 254; --g: 209; --b: 63; }
45%, 55% { --r: 109; --g: 139; --b: 191; }
72.5% { --r: 254; --g: 209; --b: 63; }
90%, 100% { --r: 255; --g: 117; --b: 40; }
}
@media (prefers-reduced-motion: reduce) {
.ed.is-demo,
.ed.is-demo .ed__loupe {
animation: none !important;
}
}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);
}
@property --r {
syntax: "<integer>";
inherits: true;
initial-value: 255;
}
@property --g {
syntax: "<integer>";
inherits: true;
initial-value: 117;
}
@property --b {
syntax: "<integer>";
inherits: true;
initial-value: 40;
}
@counter-style hx {
system: numeric;
symbols: "0" "1" "2" "3" "4" "5" "6" "7" "8" "9" "A" "B" "C" "D" "E" "F";
pad: 2 "0";
}
.ed {
position: relative;
box-sizing: border-box;
width: 288px;
max-width: 100%;
padding: 12px;
border-radius: 16px;
background: #fff7e6;
color: #17141a;
font-family: inherit;
}
.ed * {
box-sizing: border-box;
}
.ed__box {
position: relative;
}
.ed__box:focus-visible {
outline: 3px solid #2f6df6;
outline-offset: 3px;
}
.ed__canvas {
display: block;
width: 264px;
max-width: 100%;
height: 88px;
border-radius: 8px;
cursor: crosshair;
touch-action: none;
}
.ed__loupe {
position: absolute;
left: 0;
top: 0;
width: 36px;
height: 36px;
border-radius: 50%;
border: 3px solid #ffffff;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: rgb(var(--r), var(--g), var(--b));
pointer-events: none;
transform: translate(40px, 28px) translate(-50%, -50%);
}
.ed__loupe::after {
content: "";
position: absolute;
left: 50%;
top: 50%;
width: 8px;
height: 8px;
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.92);
transform: translate(-50%, -50%);
}
.ed__foot {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
}
.ed__chip {
width: 24px;
height: 24px;
border-radius: 4px;
background: rgb(var(--r), var(--g), var(--b));
}
.ed__hex {
counter-set: cr calc(var(--r)) cg calc(var(--g)) cb calc(var(--b));
font-size: 14px;
font-weight: 700;
line-height: 1.2;
letter-spacing: 0.04em;
}
.ed__hex::after {
content: "#" counter(cr, hx) counter(cg, hx) counter(cb, hx);
}
.ed__pick {
margin-left: auto;
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border: 2px solid rgba(23, 20, 26, 0.16);
border-radius: 999px;
background: transparent;
color: inherit;
cursor: pointer;
font-family: inherit;
font-size: 11px;
font-weight: 700;
line-height: 1.2;
}
.ed__pick svg {
flex: 0 0 auto;
width: 14px;
height: 14px;
}
.ed__pick:focus-visible {
outline: 3px solid #2f6df6;
outline-offset: 2px;
}
.ed__live {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.ed.is-demo .ed__loupe {
animation-name: edMove;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.ed.is-demo {
animation-name: edColor;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes edMove {
0% {
transform: translate(40px, 28px) translate(-50%, -50%);
}
22.5% {
transform: translate(132px, 28px) translate(-50%, -50%);
}
45%, 55% {
transform: translate(224px, 28px) translate(-50%, -50%);
}
72.5% {
transform: translate(132px, 28px) translate(-50%, -50%);
}
90%, 100% {
transform: translate(40px, 28px) translate(-50%, -50%);
}
}
@keyframes edColor {
0% {
--r: 255;
--g: 117;
--b: 40;
}
22.5% {
--r: 254;
--g: 209;
--b: 63;
}
45%, 55% {
--r: 109;
--g: 139;
--b: 191;
}
72.5% {
--r: 254;
--g: 209;
--b: 63;
}
90%, 100% {
--r: 255;
--g: 117;
--b: 40;
}
}
@media (prefers-reduced-motion: reduce) {
.ed.is-demo,
.ed.is-demo .ed__loupe {
animation: none !important;
}
}(function () {
var root = document.querySelector('.ed');
var box = document.getElementById('edBox');
var canvas = document.getElementById('edCanvas');
var loupe = document.getElementById('edLoupe');
var pickBtn = document.getElementById('edPick');
var live = root.querySelector('.ed__live');
var spotFormat = '가로 %x픽셀 지점, %c';
var liveFormat = '집은 색 %c';
var ctx = canvas.getContext('2d');
var sx = 40, sy = 28;
// 위쪽 56px 은 가로 3색 띠 — 채널마다 1차 보간이라 x 좌표와 색이 정확히 대응한다.
// 아래 32px 은 장식이라 미리보기 루프의 스포이드 경로가 지나가지 않는다
function paint() {
var band = ctx.createLinearGradient(0, 0, 264, 0);
band.addColorStop(0, '#ff4d1f');
band.addColorStop(0.5, '#ffd23f');
band.addColorStop(1, '#2f6df6');
ctx.fillStyle = band;
ctx.fillRect(0, 0, 264, 56);
ctx.fillStyle = '#17141a';
ctx.fillRect(0, 56, 264, 32);
var dots = [[34, 72, 10, '#ff9ebb'], [86, 72, 13, '#4cd4a6'], [146, 72, 9, '#b9a0ff'], [206, 72, 12, '#6ec8ff']];
for (var i = 0; i < dots.length; i++) {
ctx.beginPath();
ctx.arc(dots[i][0], dots[i][1], dots[i][2], 0, Math.PI * 2);
ctx.fillStyle = dots[i][3];
ctx.fill();
}
}
function toHex(r, g, b) {
return '#' + [r, g, b].map(function (n) { return n.toString(16).padStart(2, '0'); }).join('').toUpperCase();
}
// 글자는 JS 가 쓰지 않는다 — --r --g --b 세 값만 넘기면 계수기가 HEX 를 찍고 칩·루페가 같은 색이 된다
function setColor(r, g, b) {
root.style.setProperty('--r', r);
root.style.setProperty('--g', g);
root.style.setProperty('--b', b);
loupe.style.transform = 'translate(' + sx + 'px, ' + sy + 'px) translate(-50%, -50%)';
var hex = toHex(r, g, b);
box.setAttribute('aria-valuenow', sx);
box.setAttribute('aria-valuetext', spotFormat.replace('%x', sx).replace('%c', hex));
live.textContent = liveFormat.replace('%c', hex);
}
function sample() {
var px = ctx.getImageData(sx, sy, 1, 1).data; // 그 한 점의 RGBA
setColor(px[0], px[1], px[2]);
}
function moveTo(e) {
var rect = canvas.getBoundingClientRect();
sx = Math.round(Math.min(263, Math.max(0, (e.clientX - rect.left) / rect.width * 264)));
sy = Math.round(Math.min(87, Math.max(0, (e.clientY - rect.top) / rect.height * 88)));
sample();
}
canvas.addEventListener('pointermove', function (e) {
root.classList.remove('is-demo');
moveTo(e);
});
canvas.addEventListener('pointerdown', function (e) {
root.classList.remove('is-demo');
moveTo(e);
});
box.addEventListener('keydown', function (e) {
var step = e.shiftKey ? 16 : 4;
if (e.key === 'ArrowRight') { sx = Math.min(263, sx + step); }
else if (e.key === 'ArrowLeft') { sx = Math.max(0, sx - step); }
else if (e.key === 'ArrowDown') { sy = Math.min(87, sy + step); }
else if (e.key === 'ArrowUp') { sy = Math.max(0, sy - step); }
else { return; }
e.preventDefault();
root.classList.remove('is-demo');
sample();
});
pickBtn.addEventListener('click', function () {
root.classList.remove('is-demo');
// 크롬 계열은 화면 어디서나 집는다. 없는 브라우저는 캔버스 위 집기로 조용히 내려간다
if ('EyeDropper' in window) {
new window.EyeDropper().open().then(function (res) {
var n = parseInt(res.sRGBHex.slice(1), 16);
setColor((n >> 16) & 255, (n >> 8) & 255, n & 255);
}).catch(function () { sample(); });
} else {
sample();
}
});
paint();
sample();
})();// EyedropperCanvas — vanilla 판(run/286/demo/09-color-picker-eyedropper-canvas) 의 React 포트.
// 변수 3개(duration·easing·color)는 props 로 받아 CSS 변수(var(--duration) 등)로 들어온다.
// 상태는 집은 점의 좌표와 RGB 세 값뿐이다. HEX 글자는 JS 가 쓰지 않는다 —
// --r --g --b 만 넘기면 module.scss 의 counter-set 과 @counter-style hx 가 16진으로 찍는다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react";
import styles from "./EyedropperCanvas.module.scss";
type KeyEvt = { key: string; shiftKey: boolean; preventDefault(): void };
/** 화면 전체에서 집는 브라우저 API. 없는 브라우저에서는 캔버스 위 집기로 내려간다 */
type EyeDropperResult = { sRGBHex: string };
type EyeDropperLike = { open(): Promise<EyeDropperResult> };
type EyeDropperCtor = new () => EyeDropperLike;
export type EyedropperCanvasProps = {
boxLabel?: string;
canvasLabel?: string;
pickLabel?: string;
/** "가로 %x픽셀 지점, %c" 처럼 자리표시자 둘을 가진 틀 */
spotFormat?: string;
/** "집은 색 %c" 처럼 자리표시자 하나를 가진 틀 */
liveFormat?: string;
/** 격자 미리보기용 2초 자동 루프. 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
"--r"?: number;
"--g"?: number;
"--b"?: number;
};
const WIDTH = 264;
const HEIGHT = 88;
function toHex(r: number, g: number, b: number): string {
return "#" + [r, g, b].map((n) => n.toString(16).padStart(2, "0")).join("").toUpperCase();
}
export default function EyedropperCanvas({
boxLabel = "그림 위에서 색 집기",
canvasLabel = "주황에서 노랑을 지나 파랑으로 가는 색 띠",
pickLabel = "화면에서 집기",
spotFormat = "가로 %x픽셀 지점, %c",
liveFormat = "집은 색 %c",
demo = true,
duration = 2000,
easing = "linear",
color = "#2f6df6",
}: EyedropperCanvasProps) {
const [live, setLive] = useState(demo);
const [spot, setSpot] = useState({ x: 40, y: 28 });
const [rgb, setRgb] = useState({ r: 255, g: 117, b: 40 });
const canvasRef = useRef<HTMLCanvasElement>(null);
// 위쪽 56px 은 가로 3색 띠 — 채널마다 1차 보간이라 x 좌표와 색이 정확히 대응한다.
// 아래 32px 은 장식이라 미리보기 루프의 스포이드 경로가 지나가지 않는다
useEffect(() => {
const node = canvasRef.current;
if (!node) return;
const ctx = node.getContext("2d");
if (!ctx) return;
const band = ctx.createLinearGradient(0, 0, WIDTH, 0);
band.addColorStop(0, "#ff4d1f");
band.addColorStop(0.5, "#ffd23f");
band.addColorStop(1, "#2f6df6");
ctx.fillStyle = band;
ctx.fillRect(0, 0, WIDTH, 56);
ctx.fillStyle = "#17141a";
ctx.fillRect(0, 56, WIDTH, 32);
const dots: [number, number, number, string][] = [
[34, 72, 10, "#ff9ebb"],
[86, 72, 13, "#4cd4a6"],
[146, 72, 9, "#b9a0ff"],
[206, 72, 12, "#6ec8ff"],
];
dots.forEach(([cx, cy, radius, fill]) => {
ctx.beginPath();
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
ctx.fillStyle = fill;
ctx.fill();
});
}, []);
function sample(x: number, y: number) {
const node = canvasRef.current;
if (!node) return;
const ctx = node.getContext("2d");
if (!ctx) return;
const px = ctx.getImageData(x, y, 1, 1).data; // 그 한 점의 RGBA
setSpot({ x, y });
setRgb({ r: px[0], g: px[1], b: px[2] });
}
function moveTo(e: ReactPointerEvent<HTMLCanvasElement>) {
const node = canvasRef.current;
if (!node) return;
const rect = node.getBoundingClientRect();
const x = Math.round(Math.min(WIDTH - 1, Math.max(0, ((e.clientX - rect.left) / rect.width) * WIDTH)));
const y = Math.round(Math.min(HEIGHT - 1, Math.max(0, ((e.clientY - rect.top) / rect.height) * HEIGHT)));
setLive(false);
sample(x, y);
}
function keyBox(e: KeyEvt) {
const step = e.shiftKey ? 16 : 4;
let { x, y } = spot;
if (e.key === "ArrowRight") x = Math.min(WIDTH - 1, x + step);
else if (e.key === "ArrowLeft") x = Math.max(0, x - step);
else if (e.key === "ArrowDown") y = Math.min(HEIGHT - 1, y + step);
else if (e.key === "ArrowUp") y = Math.max(0, y - step);
else return;
e.preventDefault();
setLive(false);
sample(x, y);
}
function pickFromScreen() {
setLive(false);
// 크롬 계열은 화면 어디서나 집는다. 없는 브라우저는 캔버스 위 집기로 조용히 내려간다
const ctor = (window as unknown as { EyeDropper?: EyeDropperCtor }).EyeDropper;
if (ctor) {
new ctor()
.open()
.then((res) => {
const n = parseInt(res.sRGBHex.slice(1), 16);
setRgb({ r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 });
})
.catch(() => sample(spot.x, spot.y));
} else {
sample(spot.x, spot.y);
}
}
const hex = toHex(rgb.r, rgb.g, rgb.b);
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
"--r": rgb.r,
"--g": rgb.g,
"--b": rgb.b,
};
// 확대경은 자기 중심이 집은 점 위에 놓인다 — 좌표 이동 뒤 자기 크기의 절반만큼 되민다
const loupeStyle: CSSProperties = {
transform: `translate(${spot.x}px, ${spot.y}px) translate(-50%, -50%)`,
};
return (
<div className={[styles.ed, live ? styles.isDemo : ""].filter(Boolean).join(" ")} style={vars}>
<div
className={styles.box}
role="slider"
tabIndex={0}
aria-label={boxLabel}
aria-valuemin={0}
aria-valuemax={WIDTH}
aria-valuenow={spot.x}
aria-valuetext={spotFormat.replace("%x", String(spot.x)).replace("%c", hex)}
onKeyDown={keyBox}
>
<canvas
ref={canvasRef}
className={styles.canvas}
width={WIDTH}
height={HEIGHT}
role="img"
aria-label={canvasLabel}
onPointerDown={moveTo}
onPointerMove={moveTo}
/>
<span className={styles.loupe} style={loupeStyle} aria-hidden="true" />
</div>
<div className={styles.foot}>
<span className={styles.chip} aria-hidden="true" />
<span className={styles.hex} aria-hidden="true" />
<button type="button" className={styles.pick} onClick={pickFromScreen}>
<svg viewBox="0 0 256 256" fill="currentColor" aria-hidden="true">
<path
d="M207.8,87.6l-25.37,25.53,4.89,4.88a16,16,0,0,1,0,22.64l-9,9a8,8,0,0,1-11.32,0l-60.68-60.7a8,8,0,0,1,0-11.32l9-9a16,16,0,0,1,22.63,0l4.88,4.89,25-25.11c10.79-10.79,28.37-11.45,39.45-1A28,28,0,0,1,207.8,87.6Z"
opacity="0.2"
/>
<path d="M224,67.3a35.79,35.79,0,0,0-11.26-25.66c-14-13.28-36.72-12.78-50.62,1.13L142.8,62.2a24,24,0,0,0-33.14.77l-9,9a16,16,0,0,0,0,22.64l2,2.06-51,51a39.75,39.75,0,0,0-10.53,38l-8,18.41A13.68,13.68,0,0,0,36,219.3a15.92,15.92,0,0,0,17.71,3.35L71.23,215a39.89,39.89,0,0,0,37.06-10.75l51-51,2.06,2.06a16,16,0,0,0,22.62,0l9-9a24,24,0,0,0,.74-33.18l19.75-19.87A35.75,35.75,0,0,0,224,67.3ZM97,193a24,24,0,0,1-24,6,8,8,0,0,0-5.55.31l-18.1,7.91L57,189.41a8,8,0,0,0,.25-5.75A23.88,23.88,0,0,1,63,159l51-51,33.94,34ZM202.13,82l-25.37,25.52a8,8,0,0,0,0,11.3l4.89,4.89a8,8,0,0,1,0,11.32l-9,9L112,83.26l9-9a8,8,0,0,1,11.31,0l4.89,4.89a8,8,0,0,0,5.65,2.34h0a8,8,0,0,0,5.66-2.36l24.94-25.09c7.81-7.82,20.5-8.18,28.29-.81a20,20,0,0,1,.39,28.7Z" />
</svg>
<span>{pickLabel}</span>
</button>
</div>
<span className={styles.liveRegion} aria-live="polite">
{liveFormat.replace("%c", hex)}
</span>
</div>
);
}09 · 그림 위 픽셀을 집어 오는 스포이드 — 측정 누적면적 7.856% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
집기 버튼은 EyeDropper API 가 있으면 화면 전체에서 집고 없으면 캔버스 위 집기로 내려갑니다.
// 위쪽 56px 은 가로 3색 띠 — 채널마다 1차 보간이라 x 좌표와 색이 정확히 대응한다
var band = ctx.createLinearGradient(0, 0, 264, 0);
band.addColorStop(0, '#ff4d1f');
band.addColorStop(0.5, '#ffd23f');
band.addColorStop(1, '#2f6df6');
var px = ctx.getImageData(sx, sy, 1, 1).data; // 그 한 점의 RGBA
// 크롬 계열은 화면 어디서나 집는다. 없는 브라우저는 캔버스 위 집기로 조용히 내려간다
if ('EyeDropper' in window) {
new window.EyeDropper().open().then(function (res) {
var n = parseInt(res.sRGBHex.slice(1), 16);
setColor((n >> 16) & 255, (n >> 8) & 255, n & 255);
}).catch(function () { sample(); });
} else {
sample();
}
어디서 깨지는가 — 함정
가장 먼저 부딪힌 것은 색과 글자가 따로 놀았다는 것입니다. 첫 판의 01 에서는 칩 배경은 CSS 가, HEX 글자는 JS textContent 가 따로 쓰게 만들었더니 미리보기 루프에서 색은 이미 바뀌었는데 글자가 한 박자 늦는 프레임이 생겼습니다 — 두 경로가 갈라지는 순간 피커는 예쁘든 아니든 믿을 수 없어집니다. 아홉 벌 전부 색과 숫자를 등록 속성 하나에만 담고 counter-set 이 값을 계수기로 옮겨 찍게 고쳤습니다. 글자를 쓰는 JS 코드가 사라지니 어긋날 경로 자체가 없어졌습니다.
두 번째는 보간의 기준이었습니다. 03 의 명도 루프를 처음엔 $ease-spring 으로 돌렸더니, 이징 곡선이 위치와 색에 같은 비율로 걸리지 않아 RGB 줄과 HSL 줄이 같은 색을 가리키지 않는 프레임이 나왔습니다. 색 공간과 이징은 모두 선형 보간 위에서만 정확히 대응합니다 — 그래서 03 은 색상 212·채도 90% 를 고정해 RGB 가 명도에 완전히 비례하는 구간만 지나게 하고 이징을 linear 로 통일했습니다. 02 의 손잡이도 마찬가지입니다. conic-gradient(from 90deg) 는 빨강을 3시에 두는데 Math.atan2 도 0 을 3시에 둡니다 — 두 기준이 같다는 것을 확인한 뒤에야 보정 없이 각도를 그대로 색상값으로 쓸 수 있었습니다.
세 번째는 버튼 라벨이 아니라 버튼의 폭이었습니다. 06 의 배색 탭은 이미 grid-template-columns: repeat(3, 1fr) 로 세 칸을 같은 폭으로 나눠 놓았는데도 눌린 탭에 배경색이 켜지는 순간 폭이 흔들렸습니다 — <button> 은 격자 칸 안에 있어도 스스로는 글자 폭만큼만 커지는 성질이 있어, 칸을 채우는 width: 100% 를 명시해야 했습니다. 상태 교체는 steps(1, end) 로 한 프레임에 컷했고 컷만 남기면 움직인 프레임이 두세 장으로 떨어져 측정 하한을 못 넘어, 칩마다 40 밀리초씩 밀린 scale 팝을 덧붙여 프레임을 채웠습니다. 눌린 탭의 표시도 같은 프레임에서 함께 컷했습니다. 미리보기는 삼각 배색을 보여주는데 눌린 탭이 아직 보색을 가리키고 있으면, 코드가 틀린 게 아니라 화면이 거짓말을 하는 셈이 됩니다.
05 의 기록도 같은 함정을 다른 모양으로 만났습니다. 칸을 실제로 옆으로 미는 방식으로 짜면 여덟 번째 칸이 사라지는 순간 트랙이 한 번 튀고, 루프가 제자리로 돌아올 때 텔레포트가 생깁니다. 그래서 칸은 한 픽셀도 움직이지 않게 두고 색만 steps(1, end) 로 갈아끼우되, 칸마다 위상을 0.25 초씩 어긋나게 했습니다. 여덟 색이 0.25 초마다 한 칸씩 옮겨 가는 것이 곧 「앞에 하나 끼어들고 뒤가 밀린다」 이고, 2 초 뒤에는 정확히 처음 색으로 돌아옵니다.
마지막은 아홉 벌이 공유하는 기하입니다. 카드 폭 288px 에 안쪽 264px — 트랙을 짓는 네 벌(01·04·08·09)이 전부 이 264px 위에서 손잡이 자리를 계산하고, 04 와 08 은 「264px 의 1% 는 2.64px」 한 줄로 값을 그대로 픽셀로 옮깁니다. 이 한 줄이 틀어지면 손잡이가 선 자리와 화면에 찍히는 값이 갈라져, 표시기는 그 자리 색을 말하는데 숫자는 딴 자리를 말하게 됩니다. 같은 이유로 09 의 확대경은 자리와 세 색 값을 같은 퍼센트·같은 linear 이징으로 움직입니다 — 캔버스 그라디언트가 채널마다 1차 보간이라 위치를 선형으로 옮기면 색도 선형으로 따라와, 어느 프레임에서도 확대경 색이 그 자리 픽셀과 같습니다. 고른 색을 사람 얼굴 자리에 얹어 쓰는 쪽은 아바타 UI가 이름에서 색을 뽑는 방식으로 같은 문제를 풉니다. 아홉 벌의 소스와 이 글이 잰 측정값이 통째로 zip 으로 들어 있고, 여는 비밀번호는 zg928jh3 입니다.
접근성
움직임을 줄이는 설정(prefers-reduced-motion: reduce)에서 아홉 벌은 전부 같은 원칙을 따릅니다 — 스스로 도는 미리보기 루프는 끄고, 피커 자체는 완성된 상태로 남습니다. 정적 규칙을 먼저 쓰고 그 위에 .is-demo 루프를 덧씌우는 구조라 애니메이션을 꺼도 01 의 손잡이는 자리에 있고 07 의 AA 배지는 켜진 채로(AAA 는 꺼진 채 「미달」을 단 채로) 남고 09 의 확대경은 그림 위에 서 있습니다. reduce 블록의 선언에는 전부 !important 가 붙어 있는데, 루프 쪽이 클래스를 겹쳐 쌓은 모양이라 특이도에서 밀리지 않게 하려는 것입니다.
키보드는 여섯 벌의 슬라이더(01·02·04·06·08·09)가 role="slider" 와 aria-valuetext 를 가집니다 — 값뿐 아니라 「채도 94%, 명도 55%, #08468C」 처럼 숫자와 색을 같은 문장으로 읽어 줍니다. 화살표 키는 ±소폭, Shift 를 누르면 큰 폭입니다. 색만으로 상태를 전달하지 않는다는 원칙은 05 와 07 이 지킵니다 — 고른 칸은 테두리 고리에 HEX 라벨을 함께, 배지는 「통과」·「미달」 글자를 함께 붙입니다. 07 의 비율 출력은 aria-live="polite" 로 바뀔 때마다 다시 읽힙니다.
피커 안 글자의 대비는 이 페이지가 쓰는 공식으로 직접 계산한 값입니다.
| 자리 | 앞·뒤 색 | 실측 대비 | 기준 |
|---|---|---|---|
| 01 카드 글자 | 잉크 / 크림 | 17.11:1 | AA · AAA 통과 |
| 02 카드 글자 | 크림 / 잉크 | 17.11:1 | AA · AAA 통과 |
| 03 카드 글자 | 잉크 / 크림 | 17.11:1 | AA · AAA 통과 |
| 07 견본 글자(흰) | 흰 / 파랑 #2F6DF6 | 4.53:1 | AA 통과 |
| 09 무대 글자 | 잉크 / 오렌지 | 5.5:1 | AA 통과 |
| 08 코드 글자(잉크 74%) | 잉크 / 크림 | 7.57:1 | AA · AAA 통과 |
07 의 4.53:1 은 데모 자체가 재어 주는 값입니다 — 07 이 움직이는 다섯 배경 중 #2F6DF6 단계가 흰 글자에 대해 정확히 4.53 을 내어 AA 문턱(4.5)을 넘는 순간을 그대로 보여 줍니다. 이 표의 계산 근거는 MDN 의 WCAG 명암비 문서와 W3C WCAG 2.x 이해 문서를 따랐습니다.
색을 고르는 다른 축은 세그먼트 컨트롤 선택 UI 9종에, 값이 아니라 날짜를 고르는 피커는 달력 선택 UI 9종에 모여 있고, 이 사이트가 데모를 어떻게 렌더하고 잰 뒤에 글을 쓰는지는 소개에 적어 두었습니다.
FAQ
css 컬러피커를 라이브러리 없이 만들면 브라우저 호환성은 어떻게 되나요?
이 편이 쓴 기술의 지원 범위는 넓습니다 — 2026-09-12 caniuse 실측으로 @property 는 전역 95.01%(Chrome 85+·Edge 85+·Firefox 128+·Safari 16.4+), counter-set 은 95.27%, @counter-style 은 95.29% 입니다. 예외는 09 의 EyeDropper API 로 전역 30.8%(Chrome·Edge 95+, Firefox·Safari 지원 없음)라서, 지원을 확인한 뒤 없으면 캔버스 위 집기로 내려가는 폴백을 같이 넣었습니다. 같은 캔버스를 잘라내기 쪽으로 쓰면 이미지 크롭 편집기의 아홉 벌이 됩니다.
<input type="color"> 를 쓰면 되지 않나요?
간단한 자리에서는 그쪽을 쓰는 게 맞습니다. 다만 네이티브 색 입력은 브라우저마다 창이 다르고 표기 동기·알파·배색·명암비 같은 기능은 아예 없습니다. 이 편의 부품은 그 기능이 필요해지는 순간에 꺼내 쓰는 쪽입니다 — 01 의 격자 하나만 쓰더라도 표기와 손잡이가 어긋나지 않는 구조가 이미 들어 있습니다.
고른 색을 사이트에 저장하고 싶으면 어디를 고치나요?
05 가 그 뼈대입니다. localStorage 키 이름(godrich-recent-colors)과 slice(0, 8) 의 8 이 목록 길이입니다. 서버에 남기고 싶으면 save() 함수 안의 한 줄만 바꾸면 됩니다 — 읽고 쓰는 모양이 이미 한 곳에 모여 있기 때문입니다.









