DEV Community

Cover image for I Built a Color Wheel for React published
usapop
usapop

Posted on

I Built a Color Wheel for React published

I built an accessible, unstyled color wheel library using the Compound Components pattern. Here's what I learned.

Introduction

I've been working on a browser-based drawing app recently, and needed to build a color picker. I thought it might be a good learning experience to properly package it as a library and publish it to npm.

This article covers the library itself and some implementation details. I hope it might be useful to someone working on something similar.

📦 npm: react-hsv-ring
🔗 GitHub: usapopopooon/react-hsv-ring
📖 Storybook: Live Demo


What is this library?

Installation

npm install react-hsv-ring
Enter fullscreen mode Exit fullscreen mode

The only peer dependency is React 18 or 19.

Basic Usage

import { useState } from 'react'
import * as ColorWheel from 'react-hsv-ring'

function App() {
  const [color, setColor] = useState('#3b82f6')

  return (
    <ColorWheel.Root value={color} onValueChange={setColor}>
      <ColorWheel.Wheel size={200} ringWidth={20}>
        <ColorWheel.HueRing />
        <ColorWheel.HueThumb />
        <ColorWheel.Area />
        <ColorWheel.AreaThumb />
      </ColorWheel.Wheel>
    </ColorWheel.Root>
  )
}
Enter fullscreen mode Exit fullscreen mode

If you've used Radix UI before, this pattern should look familiar — the import * as ComponentName style with dot notation for subcomponents.

A More Complete Example

import { useState } from 'react'
import * as ColorWheel from 'react-hsv-ring'

function ColorPicker() {
  const [color, setColor] = useState('#3b82f6')

  return (
    <ColorWheel.Root value={color} onValueChange={setColor}>
      {/* The color wheel itself */}
      <ColorWheel.Wheel size={240} ringWidth={24}>
        <ColorWheel.HueRing />
        <ColorWheel.HueThumb />
        <ColorWheel.Area />
        <ColorWheel.AreaThumb />
      </ColorWheel.Wheel>

      {/* Color display and HEX input */}
      <div className="flex items-center gap-2 mt-3">
        <ColorWheel.Swatch className="w-8 h-8 rounded" />
        <ColorWheel.HexInput className="w-20 px-2 py-1 border rounded" />
        <ColorWheel.CopyButton>Copy</ColorWheel.CopyButton>
        <ColorWheel.PasteButton>Paste</ColorWheel.PasteButton>
      </div>

      {/* Alpha slider */}
      <ColorWheel.AlphaSlider className="mt-3" />
    </ColorWheel.Root>
  )
}
Enter fullscreen mode Exit fullscreen mode

You can mix and match only the parts you need, which gives you flexibility in designing your UI.

Key Features

Compound Components Pattern

This is the pattern popularized by Radix UI and Reach UI. You can use only the components you need, from simple use cases to full-featured color pickers.

// Just the wheel
<ColorWheel.Root>
  <ColorWheel.Wheel>
    <ColorWheel.HueRing />
    <ColorWheel.HueThumb />
    <ColorWheel.Area />
    <ColorWheel.AreaThumb />
  </ColorWheel.Wheel>
</ColorWheel.Root>

// Slider-based layout
<ColorWheel.Root>
  <ColorWheel.HueSlider />
  <ColorWheel.SaturationSlider />
  <ColorWheel.BrightnessSlider />
</ColorWheel.Root>

// Just the HEX input
<ColorWheel.Root>
  <ColorWheel.HexInput />
</ColorWheel.Root>
Enter fullscreen mode Exit fullscreen mode

Accessibility

This was a non-negotiable feature for me.

  • Keyboard navigation: Arrow keys, WASD, with Shift/Alt modifiers for fine control
  • ARIA attributes: Proper role="slider" and related attributes
  • Screen reader support: Color changes are announced
Key Action
A S Decrease by 1
D W Increase by 1
Shift + Arrow Change by 10
Alt + Arrow Jump to min/max

The component works without a mouse.

Unstyled

The components come with minimal styling. You can use Tailwind CSS, plain CSS, or whatever you prefer.

<ColorWheel.Wheel
  size={240}
  ringWidth={24}
  className="shadow-lg"  // Add your own styles
>
  ...
</ColorWheel.Wheel>
Enter fullscreen mode Exit fullscreen mode

Additional Components

Several slider components are also available:

Component Purpose
HueSlider Linear hue control (bar instead of ring)
SaturationSlider Saturation control
BrightnessSlider Brightness/Value control
LightnessSlider HSL lightness control
AlphaSlider Opacity control
GammaSlider Gamma correction (independently managed)

Utility Functions

The library also exports color conversion and manipulation functions:

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'
Enter fullscreen mode Exit fullscreen mode

Technical Details

Here's some implementation details for those interested.

Why HSV?

Several color spaces could work for a color picker:

Color Space Pros Cons
RGB Simple, web standard Doesn't match human perception
HSL Intuitive, CSS-native Most vivid at 50% lightness, which can be awkward
HSV Intuitive, independent saturation and brightness Conversion is a bit more work

I chose HSV because it maps perfectly to the color wheel UI — the hue ring and saturation/brightness area correspond directly to the HSV dimensions. It's also the model used by Photoshop and Figma, so users are likely familiar with it.

Following the Radix UI Pattern

I really admire how Radix UI structures their Compound Components. I used it as a reference for this library.

Sharing State via Context API

The Root component holds the state and distributes it to children via Context:

// Root.tsx (simplified)
export const Root = forwardRef<ColorWheelRef, RootProps>(
  function Root({ value, onValueChange, children }, ref) {
    // Using Radix UI's useControllableState
    const [hexWithAlpha, setHexWithAlpha] = useControllableState({
      prop: value,
      defaultProp: defaultValue,
      onChange: onValueChange,
    })

    const hsv = useMemo(() => hexToHsv(hex), [hex])

    const contextValue = useMemo(() => ({
      hsv,
      hex,
      setHue,
      setSaturation,
      // ...
    }), [/* deps */])

    return (
      <ColorWheelContext.Provider value={contextValue}>
        {children}
      </ColorWheelContext.Provider>
    )
  }
)
Enter fullscreen mode Exit fullscreen mode

Child components simply access state via useContext.

Supporting Both Controlled and Uncontrolled Modes

The @radix-ui/react-use-controllable-state package makes it easy to support both patterns:

// Controlled (parent manages state)
const [color, setColor] = useState('#ff0000')
<ColorWheel.Root value={color} onValueChange={setColor}>

// Uncontrolled (component manages its own state)
<ColorWheel.Root defaultValue="#ff0000">
Enter fullscreen mode Exit fullscreen mode

The Zero Saturation Problem

I ran into an interesting issue during development:

Problem: When saturation is set to 0, the hue becomes unmovable.

Root cause: Converting HSV → HEX → HSV loses hue information when saturation is 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
Enter fullscreen mode Exit fullscreen mode

This is mathematically correct behavior for the HSV color space, not a bug. But it's poor UX — users expect the hue to be preserved when they decrease saturation and then increase it again.

Solution: Store the hue separately so it persists even at zero saturation:

// 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]
)
Enter fullscreen mode Exit fullscreen mode

It's a subtle detail, but these small things make a real difference in UX.

Implementing Accessibility

Keyboard Navigation

I created a shared custom hook for consistent keyboard behavior across all sliders:

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])
}
Enter fullscreen mode Exit fullscreen mode

WASD support isn't just for gamers — some keyboards don't have arrow keys.

Screen Reader Support

Color changes are announced via a live region:

<div
  role="status"
  aria-live="polite"
  aria-atomic="true"
  style={{ /* visually hidden */ }}
>
  {/* Text is dynamically inserted here */}
</div>
Enter fullscreen mode Exit fullscreen mode

The thumbs also have proper ARIA attributes:

<Thumb
  role="slider"
  aria-label="Hue"
  aria-valuemin={0}
  aria-valuemax={360}
  aria-valuenow={hsv.h}
  aria-valuetext={`${getColorNameEn(hsv.h)}, ${hsv.h} degrees`}
  // Screen readers announce "Red, 0 degrees"
/>
Enter fullscreen mode Exit fullscreen mode

Tech Stack

  • React 18/19: Latest version support
  • TypeScript: Types matter
  • Tailwind CSS v4: Took the opportunity to learn v4
  • Radix UI: Just borrowed useControllableState
  • shadcn/ui: The cn utility
  • Zod: For validation
  • Vitest: Testing
  • Storybook: Documentation and visual testing
  • ESLint v9: Flat config

Appreciating Radix UI and shadcn/ui

While I didn't use Radix UI components directly, I heavily referenced their design philosophy.

The @radix-ui/react-use-controllable-state hook was particularly helpful. Implementing controlled/uncontrolled support manually is surprisingly tricky and error-prone.

// 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
})
Enter fullscreen mode Exit fullscreen mode

From shadcn/ui, I borrowed the cn utility — a wrapper around clsx + tailwind-merge that makes class name composition clean:

import { cn } from '@/lib/utils'

<div className={cn(
  'rounded-full',
  disabled && 'opacity-50 cursor-not-allowed',
  className  // User-provided classes can override
)} />
Enter fullscreen mode Exit fullscreen mode

Having this ecosystem available made development much easier.

Challenges Along the Way

Pointer Event Handling

Drag behavior is trickier than you might expect. To continue dragging even when the cursor leaves the element, I used 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])
Enter fullscreen mode Exit fullscreen mode

Calculating Angles on the Ring

I used Math.atan2:

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 < 0) angle += 360
  if (angle >= 360) angle -= 360
  return angle
}
Enter fullscreen mode Exit fullscreen mode

Creating the Donut Shape with CSS Masks

This was tricky. The hue ring needs to be donut-shaped, which isn't straightforward in CSS.

I first tried stacking two border-radius: 50% elements, but that didn't work well with gradients. clip-path was also cumbersome for circular cutouts.

I ended up combining conic-gradient with a radial-gradient mask:

const ringStyle: React.CSSProperties = {
  borderRadius: '50%',

  // Hue gradient
  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',

  // The key: radial-gradient mask for donut shape
  mask: `radial-gradient(
    farthest-side,
    transparent calc(100% - ${ringWidth}px - 1px),
    black calc(100% - ${ringWidth}px)
  )`,
}
Enter fullscreen mode Exit fullscreen mode

The trick is using radial-gradient to make the center transparent up to ringWidth from the edge, showing only the outer ring. The - 1px is for anti-aliasing — without it, the edges look jagged.

background-origin: border-box and background-clip: border-box are also important; without them, the gradient won't apply to the border area.

Conclusion

I built this primarily for my own use, but I hope it might help others working on similar projects.

There's probably still room for improvement, so if you find bugs or have feature requests, please open an issue on GitHub. I'll do my best to address them.


References

Top comments (0)