I Built a Color Wheel for React published
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
| 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.
- ARIA role
sliderand 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
| Component | Purpose |
|---|---|
HueSlider | Linear hue control (bar) |
SaturationSlider | Saturation control |
BrightnessSlider | Brightness / value control |
LightnessSlider | HSL lightness control |
AlphaSlider | Opacity control |
GammaSlider | Gamma 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 Space | Pros | Cons |
|---|---|---|
| RGB | Simple, web standard | Doesn’t match human perception |
| HSL | Intuitive, CSS‑native | Most vivid at 50 % lightness → awkward |
| HSV | Intuitive, independent saturation & brightness | Slightly 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
| Technology | Reason |
|---|---|
| React 18/19 | Latest version support |
| TypeScript | Types matter |
| Tailwind CSS v4 | Learned v4 while building |
| Radix UI | Borrowed useControllableState |
| shadcn/ui | cn utility (wrapper around clsx + tailwind-merge) |
| Zod | Validation |
| Vitest | Testing |
| Storybook | Documentation & visual testing |
| ESLint v9 | Flat config |
While I didn’t use Radix UI components directly, I heavily referenced their design philosophy.
The@radix-ui/react-use-controllable-statehook 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
ringWidthfrom the edge, leaving only the outer ring visible. - The
‑1pxoffset 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