我为 React 构建了一个颜色轮并已发布
Source: Dev.to
📦 包
- npm:
react-hsv-ring - GitHub:
- Storybook / Live Demo:(仓库中的链接)
npm install react-hsv-ring
唯一的 peer 依赖是 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 或其他任何方式进行样式定制。
…
滑块组件
| 组件 | 用途 |
|---|---|
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)
Issue: Hue Becomes Unmovable When Saturation Is 0
Problem
当饱和度设为 0 时,色相滑块无法再移动。
Root cause
在 HSV → HEX → HSV 的转换过程中,饱和度为 0 时会丢失色相信息。
// 当饱和度为 0(灰色)时,色相为 “未定义”
const hsv = hexToHsv('#808080'); // { h: 0, s: 0, v: 50 }
// 即使原来的 h 是 180,也会变成 0,因为灰色没有色相
这在 HSV 颜色空间的数学上是正确的,但会导致糟糕的用户体验——用户期望在降低饱和度后再次提升时色相能够被保留下来。
Solution – 将色相单独存储,使其在饱和度为零时仍然保持。
// 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 支持不仅仅是给玩家准备的——有些键盘没有方向键。
颜色公告的实时区域
{/* Text is dynamically inserted here */}
拇指 ARIA 属性
技术栈
| 技术 | 原因 |
|---|---|
| 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';
拥有这样的生态系统让开发变得更加轻松。
拖拽行为
为了在光标离开元素时仍然继续拖拽,我使用了 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]);
指针位置的色相计算
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;
}
渲染色相环(甜甜圈形状)
使用纯 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;
以上所有部分共同构成了一个健壮、可访问且用户友好的颜色选择器组件。
注意事项
- er-box 也很重要;如果没有它们,渐变将不会应用到边框区域。
- 这段代码主要是我自己使用的,但希望能帮助到同样在做类似项目的其他人。
- 仍有改进空间,如果你发现 bug 或有功能需求,请在 GitHub 上提交 issue。我会尽力处理。
参考文献
- Radix UI – 复合组件模型
- WAI‑ARIA Slider Pattern – 可访问性规范
- HSL 与 HSV – 维基百科