Component
Color picker
A picker for choosing or typing a color, with savable swatches.
- #arc
- #component
- #color
- #input
- #hex
- #swatch
1280px
Install
One command installs the files and packages this item needs.
TerminalTerminalTerminalbash
npx shadcn@latest add https://dsui.vercel.app/r/color-picker.jsonFiles
components/arc/color-picker/color-picker.module.csscomponents/arc/color-picker/color-picker.tsx
npm packages
@hugeicons/core-free-icons@^4.3.5@hugeicons/react@^1.1.10motion@^14.2.0
Registry items it pulls in
Source
View as Markdowncolor-picker.module.csscolor-picker.module.csscolor-picker.module.csscss
/* The swatch is the panel's top left corner: the panel opens over it and grows out of its box. Leave room below and to the right. */
.root {
--picker-fill: var(--surface);
--picker-width: min(19rem, calc(100vw - 32px));
--checker: repeating-conic-gradient(oklch(88% 0 0) 0 25%, oklch(100% 0 0) 0 50%) 0 0 / 10px 10px;
position: relative;
display: inline-block;
font-family: var(--font-body);
letter-spacing: var(--tracking-body);
color: var(--foreground);
}
:global(:root[data-theme="dark"]) .root { --picker-fill: var(--surface-raised); --checker: repeating-conic-gradient(oklch(42% 0 0) 0 25%, oklch(30% 0 0) 0 50%) 0 0 / 10px 10px; }
.trigger, .head { display: flex; height: 44px; align-items: center; gap: 10px; padding: 6px 16px 6px 6px; }
.trigger {
border: 1px solid var(--border); border-radius: 22px; background: var(--picker-fill); box-shadow: var(--shadow-resting); color: inherit; font: inherit; cursor: pointer;
-webkit-tap-highlight-color: transparent; transition: border-color var(--duration-fast) var(--ease-standard);
}
.triggerText { display: grid; min-width: 0; text-align: start; line-height: 1.15; }
.name { color: var(--text-secondary); font-size: var(--text-xs); }
.hex { font-size: var(--text-sm); font-variant-numeric: tabular-nums; font-weight: 500; }
/* A chip over a checkerboard, so opacity reads as opacity. */
.chip { position: relative; display: block; width: 32px; height: 32px; flex: none; overflow: hidden; border-radius: 50%; background: var(--checker); }
.chip > span { position: absolute; inset: 0; border-radius: inherit; box-shadow: inset 0 0 0 1px oklch(0% 0 0 / .1); }
/* The floating layer. Its drop shadow follows the clipped shape, so the shadow grows with the panel. */
.float { position: absolute; z-index: 20; top: -1px; left: -1px; filter: drop-shadow(0 18px 36px oklch(0% 0 0 / .12)) drop-shadow(0 2px 6px oklch(0% 0 0 / .06)); pointer-events: none; }
:global(:root[data-theme="dark"]) .float { filter: drop-shadow(0 20px 44px oklch(0% 0 0 / .5)); }
.float[data-open] { pointer-events: auto; }
.panel { width: var(--picker-width); padding-bottom: 14px; border: 1px solid var(--border); border-radius: var(--radius-panel); background: var(--picker-fill); will-change: clip-path; }
.head { gap: 10px; padding-right: 6px; }
.head .triggerText { flex: 1; }
.headActions { display: flex; gap: 2px; }
.iconButton {
display: grid; width: 32px; height: 32px; place-items: center; padding: 0; border: 0; border-radius: 12px; background: transparent; color: var(--text-secondary); cursor: pointer;
transition: background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard), transform var(--duration-fast) var(--ease-standard);
}
.iconButton:active { transform: scale(.94); }
/* One column locked to the panel: a wider grade label ("AA 큰 글자") must never widen the area and sliders mid drag. */
.body { display: grid; grid-template-columns: minmax(0, 1fr); gap: 14px; padding: 8px 14px 0; }
/* Saturation runs left to right, brightness bottom to top, over the pure hue. */
.area {
position: relative; height: 164px; border-radius: 16px; cursor: crosshair; touch-action: none;
background: linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, transparent), var(--picker-hue);
box-shadow: inset 0 0 0 1px oklch(0% 0 0 / .08);
}
.areaThumb, .sliderThumb {
position: absolute; width: 22px; height: 22px; border-radius: 50%; translate: -50% -50%; cursor: grab;
box-shadow: 0 0 0 3px #fff, 0 2px 8px oklch(0% 0 0 / .3), inset 0 0 0 1px oklch(0% 0 0 / .12);
transition: scale var(--duration-spring) var(--ease-spring);
}
.areaThumb { background: var(--picker-opaque); }
.area[data-active] .areaThumb, .sliderThumb[data-active] { scale: 1.2; cursor: grabbing; }
.slider { position: relative; height: 14px; margin: 0 11px; border-radius: var(--radius-pill); cursor: pointer; touch-action: none; }
.slider::before { content: ""; position: absolute; inset: 0 -11px; border-radius: inherit; background: var(--track); box-shadow: inset 0 0 0 1px oklch(0% 0 0 / .08); }
.sliderThumb { top: 50%; background: var(--checker); }
.sliderThumb::after { content: ""; position: absolute; inset: 0; border-radius: inherit; background: var(--thumb-fill); }
/* The gradient stops sit at the thumb's travel ends, so the color under the thumb is the value it reports. */
.hue { --track: linear-gradient(to right, #f00 11px, #ff0 calc(11px + (100% - 22px) * .1666), #0f0 calc(11px + (100% - 22px) * .3333), #0ff 50%, #00f calc(11px + (100% - 22px) * .6666), #f0f calc(11px + (100% - 22px) * .8333), #f00 calc(100% - 11px)); }
.alpha { --track: linear-gradient(to right, transparent 11px, var(--picker-opaque) calc(100% - 11px)), var(--checker); }
.field { display: flex; height: 40px; align-items: stretch; overflow: hidden; margin-top: 2px; border: 1px solid var(--border); border-radius: 14px; background: var(--surface-muted); transition: border-color var(--duration-fast) var(--ease-standard); }
.field[data-invalid] { border-color: var(--danger); }
.format {
display: flex; min-width: 56px; align-items: center; justify-content: center; padding: 0 10px; border: 0; border-right: 1px solid var(--border); background: transparent;
color: var(--foreground); font: inherit; font-size: var(--text-xs); font-weight: 500; cursor: pointer; transition: background-color var(--duration-fast) var(--ease-standard);
}
.inputWrap { position: relative; display: flex; min-width: 0; flex: 1; }
.input, .morph { padding: 0 10px; font: inherit; font-size: var(--text-xs); font-variant-numeric: tabular-nums; }
.input { width: 100%; min-width: 0; border: 0; background: transparent; color: var(--foreground); caret-color: var(--foreground); }
.input[data-morphing] { color: transparent; }
.morph { position: absolute; inset: 0; display: flex; align-items: center; overflow: hidden; color: var(--foreground); pointer-events: none; white-space: nowrap; }
.error { min-height: 0; margin: -10px 2px -4px; color: var(--danger); font-size: var(--text-xs); }
.error:empty { display: none; }
.contrast { display: flex; align-items: center; gap: 10px; font-size: var(--text-sm); }
.sample { display: grid; width: 36px; height: 28px; flex: none; place-items: center; border: 1px solid var(--border); border-radius: 9px; background: var(--picker-bg); color: var(--picker-color); font-weight: 500; }
.ratio { font-variant-numeric: tabular-nums; font-weight: 500; min-width: 4.4ch; }
.against { flex: 1; min-width: 0; overflow: hidden; color: var(--text-secondary); font-size: var(--text-xs); text-overflow: ellipsis; white-space: nowrap; }
.grade { position: relative; display: inline-grid; height: 22px; align-items: center; padding: 0 8px; border-radius: var(--radius-pill); font-size: var(--text-xs); font-weight: 500; white-space: nowrap; transition: background-color var(--duration-standard) var(--ease-standard), color var(--duration-standard) var(--ease-standard); }
.grade[data-grade="pass"] { background: color-mix(in oklab, var(--success) 14%, transparent); color: var(--success); }
.grade[data-grade="large"] { background: color-mix(in oklab, var(--warning) 16%, transparent); color: var(--warning); }
.grade[data-grade="fail"] { background: color-mix(in oklab, var(--danger) 12%, transparent); color: var(--danger); }
.swatches { display: flex; align-items: center; gap: 6px; min-height: 32px; }
.add {
display: grid; width: 30px; height: 30px; flex: none; place-items: center; padding: 0; border: 1px dashed var(--border-strong); border-radius: 50%; background: transparent; color: var(--text-secondary); cursor: pointer;
transition: color var(--duration-fast) var(--ease-standard), border-color var(--duration-fast) var(--ease-standard), transform var(--duration-fast) var(--ease-standard);
}
.add:active { transform: scale(.92); }
.swatchList { display: flex; min-width: 0; flex: 1; gap: 6px; }
.swatchSlot { position: relative; flex: none; touch-action: pan-y; }
.swatch { display: grid; width: 30px; height: 30px; place-items: center; padding: 0; border: 0; border-radius: 50%; background: transparent; cursor: pointer; }
.swatchChip { width: 26px; height: 26px; transition: scale var(--duration-spring) var(--ease-spring); }
.swatch[data-current] { box-shadow: inset 0 0 0 2px var(--foreground); }
.swatch[data-current] .swatchChip { scale: .78; }
.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
@media (hover: hover) and (pointer: fine) {
.trigger:hover { border-color: var(--border-strong); }
.iconButton:hover, .format:hover { background: var(--surface-muted); color: var(--foreground); }
.format:hover { background: color-mix(in oklab, var(--foreground) 5%, transparent); }
.add:hover { border-color: var(--foreground); color: var(--foreground); }
}
@media (max-width: 360px) { .against { display: none; } }
@media (prefers-contrast: more) { .trigger, .panel, .field { border-color: var(--border-strong); } }
@media (prefers-reduced-motion: reduce) { .areaThumb, .sliderThumb, .swatchChip, .iconButton, .add { transition: none; } }
color-picker.tsxcolor-picker.tsxcolor-picker.tsxtsx
"use client";
import { useEffect, useEffectEvent, useId, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react";
import type { CSSProperties, KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent } from "react";
import { AnimatePresence, Reorder, animate, motion, useMotionValue, useTransform } from "motion/react";
import { useReducedMotion } from "../lib/use-reduced-motion";
import type { Transition } from "motion/react";
import { HugeiconsIcon } from "@hugeicons/react";
import { ColorPickerIcon } from "@hugeicons/core-free-icons";
import { TextMorph } from "../text-morph/text-morph";
import { motionTokens } from "../lib/motion-tokens";
import styles from "./color-picker.module.css";
import { UiIcon } from "../theme/ui-icon";
import { playUiSound } from "../sound/play";
export type ColorFormat = "hex" | "rgb" | "hsl" | "oklch";
export interface ColorSwatch { id: string; color: string }
/**
* A color field whose swatch grows into a full picker. Pick saturation and brightness on the area, hue and opacity on the sliders, or type a value
* in hex, RGB, HSL, or OKLCH; the format button morphs the text between them. The eyedropper appears where the browser supports it, saved
* swatches can be added, applied, dragged or moved with Alt and the arrow keys, and removed with Delete. A contrast readout compares the color with
* the background it will sit on. Arrow keys move every thumb, Shift takes bigger steps, and Escape closes the panel.
*/
export interface ColorPickerProps {
/** Any color the picker can read: hex, rgb(), hsl(), or oklch(). */
value?: string;
defaultValue?: string;
/** Receives the color as hex, with two alpha digits when it is not opaque. */
onValueChange?: (hex: string) => void;
/** The background the color will sit on, for the contrast readout. */
background?: string;
/** Name shown on the swatch, 예: "강조색". */
label?: string;
swatches?: ColorSwatch[];
defaultSwatches?: ColorSwatch[];
onSwatchesChange?: (swatches: ColorSwatch[]) => void;
/** Most saved swatches. Saving past it drops the oldest. */
maxSwatches?: number;
defaultFormat?: ColorFormat;
className?: string;
}
export type Hsva = { h: number; s: number; v: number; a: number };
type Rgba = { r: number; g: number; b: number; a: number };
const { spring, duration, ease, blur } = motionTokens;
const clamp = (value: number, min = 0, max = 1) => Math.min(max, Math.max(min, value));
const round = (value: number, digits = 0) => { const f = 10 ** digits; return Math.round(value * f) / f; };
const physical = (visualDuration: number, bounce: number): Transition => {
const root = 2 * Math.PI / (visualDuration * 1.2);
return { type: "spring", stiffness: root * root, damping: 2 * (1 - bounce) * root, mass: 1, restDelta: .0005, restSpeed: .005 };
};
/** Thumbs chase the pointer on a quick spring with a little life, so a click lands with a soft settle and a drag trails by a hair. */
const thumbSpring = physical(.26, .22);
/** Under a finger or mouse drag the thumb stays glued to the pointer: a stiff spring with no bounce, so it never trails or wobbles. */
const dragSpring = physical(.1, 0);
const openSpring = physical(spring.morph.visualDuration, .1), closeSpring = physical(spring.settle.visualDuration, spring.settle.bounce);
const instant: Transition = { duration: 0 };
/* Color math. sRGB channels are 0 to 1. */
function hsvToRgb({ h, s, v, a }: Hsva): Rgba {
const f = (n: number) => { const k = (n + h / 60) % 6; return v - v * s * Math.max(0, Math.min(k, 4 - k, 1)); };
return { r: f(5), g: f(3), b: f(1), a };
}
function rgbToHsv({ r, g, b, a }: Rgba, hue = 0): Hsva {
const max = Math.max(r, g, b), min = Math.min(r, g, b), d = max - min;
let h = hue;
if (d > 1e-6) {
if (max === r) h = 60 * (((g - b) / d) % 6);
else if (max === g) h = 60 * ((b - r) / d + 2);
else h = 60 * ((r - g) / d + 4);
if (h < 0) h += 360;
}
return { h, s: max === 0 ? 0 : d / max, v: max, a };
}
function hslToRgb(h: number, s: number, l: number, a: number): Rgba {
const k = (n: number) => (n + h / 30) % 12, c = s * Math.min(l, 1 - l);
const f = (n: number) => l - c * Math.max(-1, Math.min(k(n) - 3, 9 - k(n), 1));
return { r: f(0), g: f(8), b: f(4), a };
}
function rgbToHsl({ r, g, b }: Rgba, hue: number) {
const max = Math.max(r, g, b), min = Math.min(r, g, b), l = (max + min) / 2, d = max - min;
const s = d < 1e-6 ? 0 : d / (1 - Math.abs(2 * l - 1));
return { h: d < 1e-6 ? hue : rgbToHsv({ r, g, b, a: 1 }).h, s, l };
}
const toLinear = (c: number) => c <= .04045 ? c / 12.92 : ((c + .055) / 1.055) ** 2.4;
const fromLinear = (c: number) => c <= .0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - .055;
function rgbToOklch({ r, g, b }: Rgba, hue: number) {
const lr = toLinear(r), lg = toLinear(g), lb = toLinear(b);
const l = Math.cbrt(.4122214708 * lr + .5363325363 * lg + .0514459929 * lb);
const m = Math.cbrt(.2119034982 * lr + .6806995451 * lg + .1073969566 * lb);
const s = Math.cbrt(.0883024619 * lr + .2817188376 * lg + .6299787005 * lb);
const L = .2104542553 * l + .793617785 * m - .0040720468 * s;
const A = 1.9779984951 * l - 2.428592205 * m + .4505937099 * s;
const B = .0259040371 * l + .7827717662 * m - .808675766 * s;
const C = Math.hypot(A, B);
let H = Math.atan2(B, A) * 180 / Math.PI;
if (H < 0) H += 360;
return { l: L, c: C, h: C < .0005 ? hue : H };
}
function oklchToRgb(L: number, C: number, H: number, a: number): Rgba {
const A = C * Math.cos(H * Math.PI / 180), B = C * Math.sin(H * Math.PI / 180);
const l = (L + .3963377774 * A + .2158037573 * B) ** 3;
const m = (L - .1055613458 * A - .0638541728 * B) ** 3;
const s = (L - .0894841775 * A - 1.291485548 * B) ** 3;
return {
r: clamp(fromLinear(4.0767416621 * l - 3.3077115913 * m + .2309699292 * s)),
g: clamp(fromLinear(-1.2684380046 * l + 2.6097574011 * m - .3413193965 * s)),
b: clamp(fromLinear(-.0041960863 * l - .7034186147 * m + 1.707614701 * s)),
a,
};
}
const byte = (c: number) => Math.round(clamp(c) * 255);
const hex2 = (c: number) => byte(c).toString(16).padStart(2, "0").toUpperCase();
export function toHex(hsva: Hsva) {
const { r, g, b, a } = hsvToRgb(hsva);
return `#${hex2(r)}${hex2(g)}${hex2(b)}${a < .999 ? hex2(a) : ""}`;
}
function format(hsva: Hsva, kind: ColorFormat) {
const rgb = hsvToRgb(hsva);
const alpha = hsva.a < .999 ? ` / ${Math.round(hsva.a * 100)}%` : "";
if (kind === "hex") return toHex(hsva);
if (kind === "rgb") return `rgb(${byte(rgb.r)} ${byte(rgb.g)} ${byte(rgb.b)}${alpha})`;
if (kind === "hsl") { const { h, s, l } = rgbToHsl(rgb, hsva.h); return `hsl(${Math.round(h) % 360} ${Math.round(s * 100)}% ${Math.round(l * 100)}%${alpha})`; }
const { l, c, h } = rgbToOklch(rgb, hsva.h);
return `oklch(${round(l * 100, 1)}% ${round(c, 3)} ${round(h, 1) % 360}${alpha})`;
}
const readAlpha = (text?: string) => text === undefined ? 1 : text.endsWith("%") ? clamp(parseFloat(text) / 100) : clamp(parseFloat(text));
/** Reads hex, rgb(), hsl(), and oklch() in modern or comma syntax. Returns null for anything else. */
export function parseColor(input: string, hue = 0): Hsva | null {
const text = input.trim().toLowerCase();
let match = /^#?([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/.exec(text);
if (match) {
let digits = match[1]!;
if (digits.length <= 4) digits = digits.split("").map(d => d + d).join("");
const n = (i: number) => parseInt(digits.slice(i, i + 2), 16) / 255;
return rgbToHsv({ r: n(0), g: n(2), b: n(4), a: digits.length === 8 ? n(6) : 1 }, hue);
}
match = /^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)(?:\s*[/,]\s*([\d.]+%?))?\s*\)$/.exec(text);
if (match) return rgbToHsv({ r: clamp(+match[1]! / 255), g: clamp(+match[2]! / 255), b: clamp(+match[3]! / 255), a: readAlpha(match[4]) }, hue);
match = /^hsla?\(\s*([\d.]+)(?:deg)?[\s,]+([\d.]+)%?[\s,]+([\d.]+)%?(?:\s*[/,]\s*([\d.]+%?))?\s*\)$/.exec(text);
if (match) {
const h = +match[1]! % 360;
const next = rgbToHsv(hslToRgb(h, clamp(+match[2]! / 100), clamp(+match[3]! / 100), readAlpha(match[4])), h);
return { ...next, h };
}
match = /^oklch\(\s*([\d.]+)(%?)\s+([\d.]+)\s+([\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+%?))?\s*\)$/.exec(text);
if (match) {
const L = match[2] ? +match[1]! / 100 : +match[1]!;
return rgbToHsv(oklchToRgb(clamp(L), +match[3]!, +match[4]!, readAlpha(match[5])), hue);
}
return null;
}
/** WCAG contrast of a color, composited over the background, against that background. */
function contrast(hsva: Hsva, background: Rgba) {
const fg = hsvToRgb(hsva);
const mix = (c: number, bg: number) => c * fg.a + bg * (1 - fg.a);
const lum = (r: number, g: number, b: number) => .2126 * toLinear(r) + .7152 * toLinear(g) + .0722 * toLinear(b);
const a = lum(mix(fg.r, background.r), mix(fg.g, background.g), mix(fg.b, background.b)), b = lum(background.r, background.g, background.b);
return (Math.max(a, b) + .05) / (Math.min(a, b) + .05);
}
const level = (ratio: number) => ratio >= 7 ? "AAA" : ratio >= 4.5 ? "AA" : ratio >= 3 ? "AA 큰 글자" : "미달";
const formats: ColorFormat[] = ["hex", "rgb", "hsl", "oklch"];
const formatNames: Record<ColorFormat, string> = { hex: "Hex", rgb: "RGB", hsl: "HSL", oklch: "OKLCH" };
const subscribe = () => () => {};
function useHydrated() { return useSyncExternalStore(subscribe, () => true, () => false); }
function useEyeDropper() { return useSyncExternalStore(subscribe, () => "EyeDropper" in window, () => false); }
type EyeDropperCtor = new () => { open: () => Promise<{ sRGBHex: string }> };
/** Springs a motion value to a new target, or jumps under reduced motion. Retargeting keeps the current velocity. */
function useFollow(target: number, reduced: boolean, dragging = false) {
const value = useMotionValue(target);
useEffect(() => {
if (reduced) { value.jump(target); return; }
const controls = animate(value, target, dragging ? dragSpring : thumbSpring);
return () => controls.stop();
}, [dragging, reduced, target, value]);
return value;
}
/** Tracks a pointer drag over an element and reports its position as fractions of the element's box. */
function usePad(onMove: (x: number, y: number) => void, onActive: (active: boolean) => void) {
const pointer = useRef<number | null>(null);
const read = (event: ReactPointerEvent<HTMLElement>) => {
const box = event.currentTarget.getBoundingClientRect();
onMove(clamp((event.clientX - box.left) / box.width), clamp((event.clientY - box.top) / box.height));
};
return {
onPointerDown(event: ReactPointerEvent<HTMLElement>) {
if (event.button !== 0 || pointer.current !== null) return;
pointer.current = event.pointerId;
event.currentTarget.setPointerCapture(event.pointerId);
onActive(true);
read(event);
},
onPointerMove(event: ReactPointerEvent<HTMLElement>) { if (event.pointerId === pointer.current) read(event); },
onPointerUp(event: ReactPointerEvent<HTMLElement>) { if (event.pointerId !== pointer.current) return; pointer.current = null; onActive(false); },
onPointerCancel(event: ReactPointerEvent<HTMLElement>) { if (event.pointerId !== pointer.current) return; pointer.current = null; onActive(false); },
};
}
/** Arrow keys step by a small amount, Shift by ten, Page keys by ten, Home and End to the ends. */
function stepFor(event: ReactKeyboardEvent, axis: "x" | "y" | "both") {
const big = event.shiftKey || event.key.startsWith("Page");
const size = big ? 10 : 1;
const map: Record<string, [number, number]> = {
ArrowRight: [size, 0], ArrowLeft: [-size, 0], ArrowUp: axis === "both" ? [0, size] : [size, 0], ArrowDown: axis === "both" ? [0, -size] : [-size, 0],
PageUp: [10, 0], PageDown: [-10, 0],
};
return map[event.key] ?? null;
}
interface SliderProps { label: string; value: number; valueText: string; max: number; unit: number; onChange: (value: number) => void; className?: string; style?: CSSProperties; reduced: boolean; fill: string; onActive: (active: boolean) => void; active: boolean }
function Slider({ label, value, valueText, max, unit, onChange, className, style, reduced, fill, onActive, active }: SliderProps) {
const x = useFollow(value / max, reduced, active);
const left = useTransform(x, v => `${v * 100}%`);
const pad = usePad(fx => onChange(fx * max), onActive);
return <div className={`${styles.slider} ${className}`} style={style} {...pad}>
<motion.div className={styles.sliderThumb} role="slider" tabIndex={0} aria-label={label} aria-valuemin={0} aria-valuemax={max} aria-valuenow={Math.round(value)} aria-valuetext={valueText}
style={{ left, "--thumb-fill": fill } as unknown as CSSProperties} data-active={active || undefined}
onKeyDown={event => {
if (event.key === "Home" || event.key === "End") { event.preventDefault(); onChange(event.key === "Home" ? 0 : max); return; }
const step = stepFor(event, "x");
if (!step) return;
event.preventDefault();
onChange(clamp(value + step[0] * unit, 0, max));
}} />
</div>;
}
function Chip({ color, className }: { color: string; className?: string }) {
return <span className={`${styles.chip} ${className ?? ""}`}><span style={{ background: color }} /></span>;
}
let swatchCount = 0;
const newId = () => `swatch-${Date.now().toString(36)}-${swatchCount++}`;
export function ColorPicker({
value, defaultValue = "#2F6BFF", onValueChange, background = "#FFFFFF", label = "색상", swatches: swatchesProp, defaultSwatches, onSwatchesChange,
maxSwatches = 7, defaultFormat = "hex", className,
}: ColorPickerProps) {
const uid = useId().replace(/[^a-zA-Z0-9-]/g, "");
const hydrated = useHydrated();
const reduced = !!useReducedMotion() && hydrated;
const canPick = useEyeDropper();
const [hsva, setHsva] = useState<Hsva>(() => parseColor(value ?? defaultValue) ?? { h: 220, s: .8, v: 1, a: 1 });
const [synced, setSynced] = useState(value);
if (value !== undefined && value !== synced) {
setSynced(value);
if (value.toUpperCase() !== toHex(hsva)) { const next = parseColor(value, hsva.h); if (next) setHsva(next); }
}
const [ownSwatches, setOwnSwatches] = useState<ColorSwatch[]>(defaultSwatches ?? []);
const swatches = swatchesProp ?? ownSwatches;
const [open, setOpen] = useState(false);
const [shown, setShown] = useState(false);
const [kind, setKind] = useState<ColorFormat>(defaultFormat);
const [draft, setDraft] = useState<string | null>(null);
const [invalid, setInvalid] = useState(false);
const [morph, setMorph] = useState<{ id: number; from: string; to: string; phase: 0 | 1 } | null>(null);
const [active, setActive] = useState<"area" | "hue" | "alpha" | null>(null);
const [focusSwatch, setFocusSwatch] = useState<string | null>(null);
const rootRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const areaThumb = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const swatchRefs = useRef(new Map<string, HTMLButtonElement>());
const dragged = useRef(false);
const fieldX = useMotionValue(0);
/** Whether the panel is meant to be open right now, read when a closing animation finishes. */
const openNow = useRef(false);
const hex = toHex(hsva);
const rgb = hsvToRgb(hsva);
const css = `rgb(${byte(rgb.r)} ${byte(rgb.g)} ${byte(rgb.b)} / ${round(hsva.a, 3)})`;
const opaque = `rgb(${byte(rgb.r)} ${byte(rgb.g)} ${byte(rgb.b)})`;
const pure = `hsl(${hsva.h} 100% 50%)`;
const bg = parseColor(background);
const ratio = contrast(hsva, bg ? hsvToRgb(bg) : { r: 1, g: 1, b: 1, a: 1 });
const text = format(hsva, kind);
function commit(next: Hsva) {
setHsva(next);
const nextHex = toHex(next);
if (nextHex !== hex) onValueChange?.(nextHex);
}
function setSwatches(next: ColorSwatch[]) { if (!swatchesProp) setOwnSwatches(next); onSwatchesChange?.(next); }
// The surface: one progress value grows the swatch's box into the panel's box; the content fades in behind the leading edge.
const progress = useMotionValue(0), fade = useMotionValue(0);
const size = { W: useMotionValue(300), H: useMotionValue(420), w: useMotionValue(160), h: useMotionValue(44) };
const clipPath = useTransform(() => {
const q = clamp(progress.get());
return `inset(0px ${round((size.W.get() - size.w.get()) * (1 - q), 2)}px ${round((size.H.get() - size.h.get()) * (1 - q), 2)}px 0px round ${round(22 + 4 * q, 2)}px)`;
});
const contentOpacity = useTransform(progress, [.35, .9], [0, 1]);
const contentY = useTransform(progress, [0, 1], [-10, 0]);
useLayoutEffect(() => {
if (!shown) return;
const panel = panelRef.current, trigger = triggerRef.current;
if (!panel || !trigger) return;
const measure = () => { size.W.set(panel.offsetWidth); size.H.set(panel.offsetHeight); size.w.set(trigger.offsetWidth); size.h.set(trigger.offsetHeight); };
measure();
const observer = new ResizeObserver(measure);
observer.observe(panel);
return () => observer.disconnect();
}, [shown, size.W, size.H, size.w, size.h]);
useEffect(() => {
if (!shown) return;
if (reduced) {
progress.jump(open ? 1 : progress.get());
const controls = animate(fade, open ? 1 : 0, { duration: duration.fast, ease: [...ease.standard] });
controls.then(() => { if (!openNow.current) { progress.jump(0); setShown(false); } });
return () => controls.stop();
}
fade.jump(1);
const controls = animate(progress, open ? 1 : 0, open ? openSpring : closeSpring);
if (!open) controls.then(() => { if (!openNow.current) setShown(false); });
return () => controls.stop();
}, [fade, open, progress, reduced, shown]);
useEffect(() => {
if (open) requestAnimationFrame(() => areaThumb.current?.focus({ preventScroll: true }));
}, [open]);
function show() { playUiSound("open"); openNow.current = true; setShown(true); setOpen(true); }
function hide(returnFocus = true) {
playUiSound("close");
openNow.current = false;
setOpen(false); setDraft(null); setInvalid(false);
if (returnFocus) triggerRef.current?.focus({ preventScroll: true });
}
const dismiss = useEffectEvent((event: Event) => {
if (event instanceof KeyboardEvent) {
if (event.key !== "Escape") return;
hide(!!rootRef.current?.contains(document.activeElement));
return;
}
if (!rootRef.current?.contains(event.target as Node)) hide(false);
});
useEffect(() => {
if (!open) return;
const listener = (event: Event) => dismiss(event);
document.addEventListener("pointerdown", listener, true);
document.addEventListener("keydown", listener);
return () => { document.removeEventListener("pointerdown", listener, true); document.removeEventListener("keydown", listener); };
}, [open]);
// The saturation and brightness area.
const areaX = useFollow(hsva.s, reduced, active === "area"), areaY = useFollow(1 - hsva.v, reduced, active === "area");
const areaLeft = useTransform(areaX, v => `${v * 100}%`), areaTop = useTransform(areaY, v => `${v * 100}%`);
const areaPad = usePad((x, y) => commit({ ...hsva, s: x, v: 1 - y }), on => setActive(on ? "area" : null));
// Format morph: the new text mounts showing the old string, then morphs to the new one, and the live field returns once it settles.
useEffect(() => {
if (!morph || morph.phase === 1) return;
const frame = requestAnimationFrame(() => setMorph(current => current && current.id === morph.id ? { ...current, phase: 1 } : current));
return () => cancelAnimationFrame(frame);
}, [morph]);
useEffect(() => {
if (!morph || morph.phase === 0) return;
const timer = window.setTimeout(() => setMorph(current => current?.id === morph.id ? null : current), 620);
return () => window.clearTimeout(timer);
}, [morph]);
function cycleFormat() {
const next = formats[(formats.indexOf(kind) + 1) % formats.length]!;
setKind(next); setDraft(null); setInvalid(false);
if (!reduced) setMorph({ id: Date.now(), from: text, to: format(hsva, next), phase: 0 });
}
function submitDraft() {
if (draft === null) return true;
const next = parseColor(draft, hsva.h);
if (!next) {
setInvalid(true);
if (!reduced) animate(fieldX, [0, -5, 4, -2, 0], { duration: duration.moderate, ease: [...ease.standard] });
return false;
}
commit(next); setDraft(null); setInvalid(false);
return true;
}
function pickFromScreen() {
if (!canPick) return;
const Ctor = (window as unknown as { EyeDropper: EyeDropperCtor }).EyeDropper;
new Ctor().open().then(result => { const next = parseColor(result.sRGBHex, hsva.h); if (next) commit({ ...next, a: hsva.a }); }).catch(() => {});
}
function saveSwatch() {
const entry = { id: newId(), color: hex };
setSwatches([entry, ...swatches].slice(0, maxSwatches));
setFocusSwatch(entry.id);
}
function onSwatchKey(event: ReactKeyboardEvent<HTMLButtonElement>, index: number) {
const item = swatches[index]!;
const move = event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0;
if (event.key === "Delete" || event.key === "Backspace") {
event.preventDefault();
const next = swatches.filter(entry => entry.id !== item.id);
setSwatches(next);
const neighbor = next[Math.min(index, next.length - 1)];
setFocusSwatch(neighbor?.id ?? null);
requestAnimationFrame(() => neighbor ? swatchRefs.current.get(neighbor.id)?.focus({ preventScroll: true }) : inputRef.current?.focus({ preventScroll: true }));
return;
}
if (!move && event.key !== "Home" && event.key !== "End") return;
event.preventDefault();
if (move && event.altKey) {
const to = index + move;
if (to < 0 || to >= swatches.length) return;
const next = [...swatches];
next.splice(index, 1); next.splice(to, 0, item);
setSwatches(next);
requestAnimationFrame(() => swatchRefs.current.get(item.id)?.focus({ preventScroll: true }));
return;
}
const target = event.key === "Home" ? 0 : event.key === "End" ? swatches.length - 1 : (index + move + swatches.length) % swatches.length;
setFocusSwatch(swatches[target]!.id);
swatchRefs.current.get(swatches[target]!.id)?.focus({ preventScroll: true });
}
const ratioValue = useFollow(ratio, reduced);
const ratioText = useTransform(ratioValue, v => v.toFixed(2));
const grade = level(ratio);
const hidden = reduced ? { opacity: 0 } : { opacity: 0, y: "0.3em", filter: `blur(${blur.subtle}px)` };
const swatchTab = swatches.some(entry => entry.id === focusSwatch) ? focusSwatch : swatches[0]?.id;
const style = { "--picker-color": css, "--picker-opaque": opaque, "--picker-hue": pure, "--picker-bg": background } as CSSProperties;
return <div ref={rootRef} className={[styles.root, className].filter(Boolean).join(" ")} style={style}>
<button ref={triggerRef} type="button" className={styles.trigger} aria-haspopup="dialog" aria-expanded={open} aria-controls={open ? `${uid}-panel` : undefined}
onClick={() => open ? hide() : show()}>
<Chip color={css} />
<span className={styles.triggerText}><span className={styles.name}>{label}</span><span className={styles.hex}>{hex}</span></span>
</button>
{shown && <div className={styles.float} data-open={open || undefined}>
<motion.div ref={panelRef} id={`${uid}-panel`} role="dialog" aria-label={`${label} 색상 고르기`} className={styles.panel} inert={!open} style={{ clipPath, opacity: fade }}>
<div className={styles.head}>
<Chip color={css} />
<span className={styles.triggerText}><span className={styles.name}>{label}</span><span className={styles.hex}>{hex}</span></span>
<motion.span className={styles.headActions} style={{ opacity: contentOpacity }}>
{canPick && <button type="button" className={styles.iconButton} aria-label="화면에서 색 고르기" onClick={pickFromScreen}><HugeiconsIcon icon={ColorPickerIcon} size={18} strokeWidth={1.75} aria-hidden="true" /></button>}
<button type="button" className={styles.iconButton} aria-label="완료" onClick={() => hide()}><UiIcon iconRole="check" size={18} strokeWidth={1.75} aria-hidden="true" /></button>
</motion.span>
</div>
<motion.div className={styles.body} style={{ opacity: contentOpacity, y: contentY }}>
<div className={styles.area} data-active={active === "area" || undefined} {...areaPad}>
<motion.div ref={areaThumb} className={styles.areaThumb} role="slider" tabIndex={0} aria-roledescription="2D slider"
aria-label="채도와 밝기" aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(hsva.s * 100)}
aria-valuetext={`채도 ${Math.round(hsva.s * 100)}%, 밝기 ${Math.round(hsva.v * 100)}%`}
style={{ left: areaLeft, top: areaTop }}
onKeyDown={event => {
const step = stepFor(event, "both");
if (!step) return;
event.preventDefault();
commit({ ...hsva, s: clamp(hsva.s + step[0] / 100), v: clamp(hsva.v + step[1] / 100) });
}} />
</div>
<Slider label="색조" value={hsva.h} valueText={`${Math.round(hsva.h)}도`} max={360} unit={1} className={styles.hue} reduced={reduced} fill={pure}
onChange={h => commit({ ...hsva, h: clamp(h, 0, 359.9) })} active={active === "hue"} onActive={on => setActive(on ? "hue" : null)} />
<Slider label="불투명도" value={hsva.a * 100} valueText={`${Math.round(hsva.a * 100)}%`} max={100} unit={1} className={styles.alpha} reduced={reduced} fill={css}
onChange={a => commit({ ...hsva, a: clamp(a / 100) })} active={active === "alpha"} onActive={on => setActive(on ? "alpha" : null)} />
<motion.div className={styles.field} data-invalid={invalid || undefined} style={{ x: fieldX }}>
<button type="button" className={styles.format} onClick={cycleFormat} aria-label={`형식: ${formatNames[kind]}. 형식 바꾸기`}>
<TextMorph>{formatNames[kind]}</TextMorph>
</button>
<span className={styles.inputWrap}>
<input ref={inputRef} className={styles.input} value={draft ?? text} spellCheck={false} autoComplete="off" aria-label={`${label} ${formatNames[kind]} 값`}
aria-invalid={invalid || undefined} aria-describedby={invalid ? `${uid}-error` : undefined} data-morphing={morph ? "" : undefined}
onChange={event => { setDraft(event.target.value); setInvalid(false); }}
onKeyDown={event => {
if (event.nativeEvent.isComposing || event.keyCode === 229) return;
if (event.key === "Enter") { event.preventDefault(); if (submitDraft()) event.currentTarget.select(); }
if (event.key === "Escape" && draft !== null) { event.stopPropagation(); event.nativeEvent.stopImmediatePropagation(); setDraft(null); setInvalid(false); }
}}
onBlur={() => { if (!submitDraft()) { setDraft(null); setInvalid(false); } }} />
{morph && <span className={styles.morph} aria-hidden="true"><TextMorph>{morph.phase === 0 ? morph.from : morph.to}</TextMorph></span>}
</span>
</motion.div>
<p id={`${uid}-error`} className={styles.error} aria-live="polite">{invalid ? "HEX, RGB, HSL, OKLCH 색상 값을 입력해 주세요" : ""}</p>
<div className={styles.contrast}>
<span className={styles.sample} aria-hidden="true">Aa</span>
<span className={styles.ratio}><motion.span>{ratioText}</motion.span>:1</span>
<span className={styles.against}>배경 대비</span>
<span className={styles.grade} data-grade={grade === "미달" ? "fail" : grade === "AA 큰 글자" ? "large" : "pass"}>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span key={grade} initial={hidden} animate={{ opacity: 1, y: "0em", filter: "blur(0px)" }}
exit={{ ...hidden, transition: { duration: duration.instant } }} transition={reduced ? { duration: duration.instant } : { duration: duration.standard, ease: [...ease.enter] }}>{grade}</motion.span>
</AnimatePresence>
</span>
<span className={styles.srOnly}>{`대비 ${ratio.toFixed(2)} 대 1, ${grade === "미달" ? "기준에 못 미쳐요" : `${grade} 기준을 통과해요`}`}</span>
</div>
<div className={styles.swatches}>
<button type="button" className={styles.add} onClick={saveSwatch} aria-label={`${hex} 저장하기`}><UiIcon iconRole="plus" size={16} strokeWidth={1.75} aria-hidden="true" /></button>
<Reorder.Group as="div" axis="x" values={swatches} onReorder={setSwatches} className={styles.swatchList} role="listbox" aria-label="저장한 색상" aria-orientation="horizontal">
<AnimatePresence initial={false}>
{swatches.map((entry, index) => <Reorder.Item key={entry.id} value={entry} as="div" className={styles.swatchSlot} dragElastic={.12}
initial={reduced ? { opacity: 0 } : { opacity: 0, scale: .4 }} animate={{ opacity: 1, scale: 1 }}
exit={reduced ? { opacity: 0 } : { opacity: 0, scale: .4, transition: { duration: duration.fast } }}
transition={reduced ? instant : { ...spring.snappy, layout: spring.smooth }} whileDrag={reduced ? undefined : { scale: 1.15, zIndex: 2 }}
onDragStart={() => { dragged.current = true; }} onDragEnd={() => requestAnimationFrame(() => { dragged.current = false; })}>
<button ref={node => { if (node) swatchRefs.current.set(entry.id, node); else swatchRefs.current.delete(entry.id); }}
type="button" role="option" aria-selected={entry.color.toUpperCase() === hex} className={styles.swatch} data-current={entry.color.toUpperCase() === hex || undefined}
aria-label={`${entry.color}. Alt와 방향키로 옮기고, Delete로 지워요`} tabIndex={entry.id === swatchTab ? 0 : -1}
onFocus={() => setFocusSwatch(entry.id)} onKeyDown={event => onSwatchKey(event, index)}
onClick={() => { if (dragged.current) return; const next = parseColor(entry.color, hsva.h); if (next) { playUiSound("select"); commit(next); } }}>
<Chip color={entry.color} className={styles.swatchChip} />
</button>
</Reorder.Item>)}
</AnimatePresence>
</Reorder.Group>
</div>
</motion.div>
</motion.div>
</div>}
</div>;
}
export default ColorPicker;
Used items
- Text morphComponent
Morphs one short label into the next, keeping shared letters in place.