I Built a Color Wheel for React published

Published: (January 3, 2026 at 05:48 AM EST)
6 min read
Source: Dev.to

Source: Dev.to

📦 Package

  • npm: react-hsv-ring
  • GitHub:
  • Storybook / Live Demo: (link in repo)
npm install react-hsv-ring

The only peer dependency is React 18 (or 19).

Quick Start

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

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

  return (
    
      
        
        
        
        
      
    
  );
}

If you’ve used Radix UI before, the import * as ComponentName style with dot‑notation sub‑components will look familiar.

Full‑Featured Example

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 */}
      
    
  );
}

You can mix‑and‑match only the parts you need, giving you full flexibility when designing your UI.

Component Composition

1️⃣ Just the wheel


  
    
    
    
    
  

2️⃣ Slider‑based layout


  
  
  

3️⃣ Only the HEX input


  

Keyboard & Accessibility

KeyAction
← ↓ A SDecrease by 1
→ ↑ D WIncrease by 1
Shift + ArrowChange by 10
Alt + ArrowJump to min / max
  • The component works without a mouse.
  • ARIA role slider and related attributes are applied automatically.
  • Screen‑reader announcements describe color changes.

Styling

The components ship with minimal styling. You can style them with Tailwind CSS, plain CSS, or any other approach.



Slider Components

ComponentPurpose
HueSliderLinear hue control (bar)
SaturationSliderSaturation control
BrightnessSliderBrightness / value control
LightnessSliderHSL lightness control
AlphaSliderOpacity control
GammaSliderGamma correction (independent)

Utility 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';

Why HSV?

Color SpaceProsCons
RGBSimple, web standardDoesn’t match human perception
HSLIntuitive, CSS‑nativeMost vivid at 50 % lightness → awkward
HSVIntuitive, independent saturation & brightnessSlightly more conversion work

HSV maps perfectly to the wheel UI: the hue ring and the saturation/brightness area correspond directly to HSV dimensions. It’s also the model used by Photoshop and Figma, so users are already familiar with it.

Implementation Highlights

Root Component (state holder)

// 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}
    
  );
});

Child components simply read the shared state via useContext.

The @radix-ui/react-use-controllable-state package makes it easy to support both controlled (parent manages state) and uncontrolled (internal state) patterns:

// Controlled usage
const [color, setColor] = useState('#ff0000');

Closing Thoughts

  • The Compound Components pattern gives you granular control over UI composition while keeping the API ergonomic.
  • Full keyboard support, proper ARIA roles, and screen‑reader announcements make the library truly accessible.
  • Minimal styling means you can adopt it in any design system without fighting default CSS.

Feel free to try it out, contribute, or raise issues on GitHub! 🎨🚀

ColorWheel Component – Development Notes


// Uncontrolled (component manages its own state)

Issue: Hue Becomes Unmovable When Saturation Is 0

Problem
When saturation is set to 0, the hue slider can no longer be moved.

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

This is mathematically correct for the HSV colour space, but it creates a 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]
);

Shared Slider Keyboard Hook

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 support isn’t just for gamers – some keyboards lack arrow keys.

Live Region for Colour Announcements


  {/* Text is dynamically inserted here */}

Thumb ARIA Attributes

Tech Stack

TechnologyReason
React 18/19Latest version support
TypeScriptTypes matter
Tailwind CSS v4Learned v4 while building
Radix UIBorrowed useControllableState
shadcn/uicn utility (wrapper around clsx + tailwind-merge)
ZodValidation
VitestTesting
StorybookDocumentation & visual testing
ESLint v9Flat config

While I didn’t use Radix UI components directly, I heavily referenced their design philosophy.
The @radix-ui/react-use-controllable-state hook was especially helpful – implementing controlled/uncontrolled support manually is surprisingly tricky and error‑prone.

Controllable/Uncontrollable State Hook

// 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 Utility Example

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

Having this ecosystem available made development much easier.

Drag Behaviour

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]);

Hue Calculation from Pointer Position

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;
}

Rendering the Hue Ring (Donut Shape)

Creating a donut‑shaped hue ring isn’t straightforward with plain CSS. After trying stacked border-radius: 50% elements and clip‑path, I settled on a combination of conic‑gradient and 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)
  )`,
};
  • The radial‑gradient makes the centre transparent up to ringWidth from the edge, leaving only the outer ring visible.
  • The ‑1px offset smooths anti‑aliasing; without it the edges appear jagged.
background-origin: border-box;
background-clip: border-box;

All of the above pieces together form a robust, accessible, and user‑friendly colour picker component.

Notes

  • er-box are also important; without them, the gradient won’t apply to the border area.
  • 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

  • Radix UI – The model for Compound Components
  • WAI‑ARIA Slider Pattern – Accessibility specification
  • HSL and HSV – Wikipedia
Back to Blog

Related posts

Read more »