React용 컬러 휠을 만들었습니다 (게시됨)
Source: Dev.to
(번역할 텍스트를 제공해 주시면 한국어로 번역해 드리겠습니다.)
📦 패키지
- npm:
react-hsv-ring - GitHub:
- Storybook / Live Demo: (repo에 링크)
npm install react-hsv-ring
유일한 피어 의존성은 React 18 (또는 19)입니다.
빠른 시작
import { useState } from 'react';
import * as ColorWheel from 'react-hsv-ring';
function App() {
const [color, setColor] = useState('#3b82f6');
return (
);
}
만약 Radix UI를 사용해 본 적이 있다면, import * as ComponentName 스타일과 점 표기법을 사용한 서브 컴포넌트가 익숙하게 느껴질 것입니다.
전체 기능 예제
import { useState } from 'react';
import * as ColorWheel from 'react-hsv-ring';
function ColorPicker() {
const [color, setColor] = useState('#3b82f6');
return (
{/* The color wheel */}
{/* Color display and HEX input */}
Copy
Paste
{/* Alpha slider */}
);
}
필요한 부분만 골라서 혼합해 사용할 수 있으므로 UI를 디자인할 때 완전한 유연성을 제공합니다.
컴포넌트 구성
1️⃣ 휠만
2️⃣ 슬라이더 기반 레이아웃
3️⃣ HEX 입력만
키보드 및 접근성
| 키 | 동작 |
|---|---|
| ← ↓ A S | 1씩 감소 |
| → ↑ D W | 1씩 증가 |
| Shift + Arrow | 10씩 변경 |
| Alt + Arrow | 최소/최대로 이동 |
- 마우스 없이도 컴포넌트를 사용할 수 있습니다.
- ARIA 역할
slider와 관련 속성이 자동으로 적용됩니다. - 스크린리더 안내가 색상 변화를 설명합니다.
스타일링
컴포넌트는 최소한의 스타일링만 제공됩니다. Tailwind CSS, 일반 CSS, 혹은 다른 어떤 방법으로도 스타일을 적용할 수 있습니다.
…
슬라이더 컴포넌트
| Component | Purpose |
|---|---|
HueSlider | 선형 색조 제어 (바) |
SaturationSlider | 채도 제어 |
BrightnessSlider | 밝기 / 값 제어 |
LightnessSlider | HSL 밝기 제어 |
AlphaSlider | 불투명도 제어 |
GammaSlider | 감마 보정 (독립) |
유틸리티 함수
import {
// Conversions
hsvToHex, hexToHsv, hexToRgb, rgbToHex,
hexToHsl, hslToHex, hexToCssRgb, cssRgbToHex,
// Manipulation
lighten, darken, saturate, desaturate,
mix, complement, invert, grayscale,
// Accessibility
getContrastRatio, isReadable, suggestTextColor,
// Palette generation
generateAnalogous, generateTriadic, generateShades,
// Validation (Zod schemas)
hexSchema, rgbSchema, hslSchema,
} from 'react-hsv-ring';
왜 HSV인가?
| 색 공간 | 장점 | 단점 |
|---|---|---|
| RGB | 간단하고 웹 표준 | 인간 인지와 일치하지 않음 |
| HSL | 직관적이며 CSS‑네이티브 | 50 % 명도에서 가장 선명하지만 어색함 |
| HSV | 직관적이며 채도와 밝기가 독립적 | 약간 더 많은 변환 작업 필요 |
HSV는 완벽하게 휠 UI에 매핑됩니다: 색상 링과 채도/밝기 영역이 HSV 차원에 직접 대응합니다. 또한 Photoshop과 Figma에서 사용하는 모델이므로 사용자는 이미 익숙합니다.
구현 하이라이트
루트 컴포넌트 (상태 보유자)
// Root.tsx (simplified)
export const Root = forwardRef(function Root(
{ value, onValueChange, children },
ref,
) {
// Radix UI’s useControllableState supports controlled & uncontrolled usage
const [hexWithAlpha, setHexWithAlpha] = useControllableState({
prop: value,
defaultProp: defaultValue,
onChange: onValueChange,
});
const hsv = useMemo(() => hexToHsv(hexWithAlpha), [hexWithAlpha]);
const contextValue = useMemo(
() => ({
hsv,
hex: hexWithAlpha,
setHue,
setSaturation,
// …
}),
[hsv, hexWithAlpha],
);
return (
{children}
);
});
자식 컴포넌트는 useContext를 통해 공유된 상태를 간단히 읽습니다.
@radix-ui/react-use-controllable-state 패키지는 제어된(부모가 상태를 관리) 및 제어되지 않은(내부 상태) 패턴을 모두 쉽게 지원하도록 해줍니다:
// Controlled usage
const [color, setColor] = useState('#ff0000');
마무리 생각
- Compound Components 패턴은 UI 구성을 세밀하게 제어하면서 API를 인체공학적으로 유지합니다.
- 완전한 키보드 지원, 적절한 ARIA 역할, 그리고 스크린 리더 알림은 라이브러리를 진정으로 접근 가능하게 만듭니다.
- 최소한의 스타일링은 기본 CSS와 충돌하지 않고 어떤 디자인 시스템에서도 채택할 수 있음을 의미합니다.
GitHub에서 자유롭게 사용해보고, 기여하거나, 이슈를 제기하세요! 🎨🚀
ColorWheel 컴포넌트 – 개발 노트
// Uncontrolled (component manages its own state)
문제: 채도가 0일 때 색조가 움직이지 않음
문제
채도가 0으로 설정되면 색조 슬라이더를 더 이상 움직일 수 없습니다.
근본 원인
HSV → HEX → HSV 변환 시 채도가 0이면 색조 정보가 손실됩니다.
// When saturation is 0 (gray), hue is "undefined"
const hsv = hexToHsv('#808080'); // { h: 0, s: 0, v: 50 }
// Even if h was originally 180, it becomes 0 because gray has no hue
이는 HSV 색 공간에서는 수학적으로 올바른 것이지만, 사용자 경험이 좋지 않습니다 – 사용자는 채도를 낮췄다가 다시 높일 때 색조가 유지되길 기대합니다.
해결책 – 색조를 별도로 저장하여 채도가 0일 때도 유지되도록 합니다.
// Derived HSV (calculated from hex)
const derivedHsv = useMemo(() => hexToHsv(hex), [hex]);
// Hue is stored independently
const [preservedHue, setPreservedHue] = useState(() => derivedHsv.h);
// Use preserved hue when saturation is 0
const hsv = useMemo(
() =>
derivedHsv.s === 0
? { ...derivedHsv, h: preservedHue }
: derivedHsv,
[derivedHsv, preservedHue]
);
공유 슬라이더 키보드 훅
export function useSliderKeyboard({
value,
min,
max,
disabled,
onChange,
wrap = false, // Whether to wrap around (for hue)
}) {
return useCallback(
(e: React.KeyboardEvent) => {
if (disabled) return;
let step = 1;
if (e.shiftKey) step = 10; // Shift for 10‑unit steps
switch (e.key) {
case 'ArrowRight':
case 'ArrowUp':
case 'd':
case 'w':
e.preventDefault();
if (e.altKey) {
onChange(max); // Alt jumps to max
} else if (wrap) {
onChange((value + step) % (max + 1)); // Wrap around
} else {
onChange(Math.min(max, value + step));
}
break;
// …
}
},
[value, min, max, disabled, onChange, wrap]
);
}
WASD 지원은 게이머만을 위한 것이 아닙니다 – 화살표 키가 없는 키보드도 있습니다.
색상 알림을 위한 Live Region
{/* Text is dynamically inserted here */}
Thumb ARIA 속성
Source: …
기술 스택
| 기술 | 이유 |
|---|---|
| React 18/19 | 최신 버전 지원 |
| TypeScript | 타입이 중요함 |
| Tailwind CSS v4 | v4를 직접 사용해 보면서 학습 |
| Radix UI | useControllableState 차용 |
| shadcn/ui | cn 유틸리티 (clsx + tailwind-merge 래퍼) |
| Zod | 검증 |
| Vitest | 테스트 |
| Storybook | 문서화 및 시각적 테스트 |
| ESLint v9 | 플랫 설정 |
Radix UI 컴포넌트를 직접 사용하지는 않았지만, 그들의 디자인 철학을 많이 참고했습니다.
@radix-ui/react-use-controllable-state훅은 특히 도움이 되었는데, 제어/비제어 지원을 직접 구현하는 것이 생각보다 까다롭고 오류가 발생하기 쉽습니다.
제어 가능/제어 불가능 상태 훅
// This single hook handles both controlled and uncontrolled modes
const [value, setValue] = useControllableState({
prop: valueProp, // Value from parent
defaultProp: defaultValue, // Initial value
onChange: onValueChange, // Change callback
});
cn 유틸리티 예시
import { cn } from '@/lib/utils';
이러한 생태계가 갖춰져 있어 개발이 훨씬 수월했습니다.
Drag Behaviour
커서가 요소를 벗어나도 드래그를 계속하려면 setPointerCapture를 사용했습니다:
const handlePointerDown = useCallback((e: React.PointerEvent) => {
e.preventDefault();
(e.target as HTMLElement).setPointerCapture(e.pointerId);
onDragStart?.();
}, [onDragStart]);
const handlePointerMove = useCallback((e: React.PointerEvent) => {
// Ignore if not captured
if (!e.currentTarget.hasPointerCapture(e.pointerId)) return;
// Handle drag …
}, []);
const handlePointerUp = useCallback((e: React.PointerEvent) => {
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
onDragEnd?.();
}, [onDragEnd]);
포인터 위치에서 색상(Hue) 계산
export function getHueFromPosition(
x: number,
y: number,
centerX: number,
centerY: number,
hueOffset: number = -90
): number {
const dx = x - centerX;
const dy = y - centerY;
let angle = Math.atan2(dy, dx) * (180 / Math.PI);
angle = angle - hueOffset;
if (angle = 360) angle -= 360;
return angle;
}
Source: …
Hue 링 (도넛 형태) 렌더링
일반 CSS만으로 도넛 모양의 색상 링을 만드는 것은 쉽지 않습니다. border-radius: 50% 요소를 겹쳐 쌓거나 clip-path를 시도해 본 뒤, conic‑gradient와 radial‑gradient 마스크를 조합하는 방법으로 해결했습니다.
const ringStyle: React.CSSProperties = {
borderRadius: '50%',
// 색상 그라디언트
border: `${ringWidth}px solid transparent`,
backgroundImage: `conic-gradient(
from ${hueOffset}deg,
hsl(0, 100%, 50%),
hsl(60, 100%, 50%),
hsl(120, 100%, 50%),
hsl(180, 100%, 50%),
hsl(240, 100%, 50%),
hsl(300, 100%, 50%),
hsl(360, 100%, 50%)
)`,
backgroundOrigin: 'border-box',
backgroundClip: 'border-box',
// 핵심: 도넛 형태를 위한 radial‑gradient 마스크
mask: `radial-gradient(
farthest-side,
transparent calc(100% - ${ringWidth}px - 1px),
black calc(100% - ${ringWidth}px)
)`,
};
- radial‑gradient가 가장자리에서
ringWidth만큼 안쪽까지 중앙을 투명하게 만들어 외부 링만 보이게 합니다. ‑1px오프셋은 안티앨리어싱을 부드럽게 해 주며, 없을 경우 가장자리가 들쭉날쭉하게 보입니다.
background-origin: border-box;
background-clip: border-box;
위의 모든 요소가 합쳐져 견고하고 접근성이 뛰어나며 사용자 친화적인 컬러 피커 컴포넌트를 구성합니다.
참고 사항
background-origin과background-clip은 매우 중요합니다; 이 설정이 없으면 그라디언트가 테두리 영역에 적용되지 않습니다.- 저는 주로 개인 용도로 이 코드를 만들었지만, 비슷한 프로젝트를 진행하는 다른 분들에게도 도움이 되길 바랍니다.
- 아직 개선할 여지는 충분히 있으니, 버그를 발견하거나 기능 요청이 있으면 GitHub에 이슈를 열어 주세요. 최선을 다해 대응하겠습니다.
참조
- Radix UI – 복합 컴포넌트 모델
- WAI‑ARIA Slider Pattern – 접근성 사양
- HSL 및 HSV – Wikipedia