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
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>
)
}
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>
)
}
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>
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>
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'
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>
)
}
)
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">
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
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]
)
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])
}
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>
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"
/>
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
cnutility - 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
})
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
)} />
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])
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
}
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)
)`,
}
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
- Radix UI - The model for Compound Components
- WAI-ARIA Slider Pattern - Accessibility specification
- HSL and HSV - Wikipedia
Top comments (0)