我为 React 构建了一个颜色轮并已发布

发布: (2026年1月3日 GMT+8 18:48)
10 min read
原文: Dev.to

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 UIimport * 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亮度/数值控制
LightnessSliderHSL 亮度控制
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/uicn 实用工具(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‑gradientradial‑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 – 维基百科
Back to Blog

相关文章

阅读更多 »

让数据可视化可听

介绍 大约十年前,我参加了一个 Web Audio API hackathon,并产生了一个副项目的想法:如果盲人用户能够“听到”数据可视化会怎样……