EmpireUI
Get Pro
← Blog8 min read#color picker#react#hsl

Color Picker in React: HSL Slider, Hex Input and Copy Button

Build a fully controlled React color picker with HSL sliders, live hex input, and a one-click copy button — no third-party library needed.

Developer laptop screen showing colorful code with gradient hues

Why Roll Your Own Color Picker?

Honest answer: you probably don't need to. But hear me out. Every time you reach for a package like react-color or react-colorful, you're pulling in code you can't fully control, styling that fights your design system, and an API that was frozen circa 2021. In practice, a bespoke color picker is maybe 120 lines of TypeScript — and you own every pixel of it.

The browser's native <input type="color"> is laughably inconsistent across platforms. On macOS it opens the system palette, on Windows you get a flat hex grid, on mobile it's basically a gamble. If you're building a tool where color accuracy matters — a gradient generator, a design token editor, anything like that — the native input will disappoint you or your users. Probably both.

What we're building here is a three-part component: an HSL hue slider (that's the rainbow strip you see in every color tool), a saturation/lightness preview swatch, and a hex input with a copy-to-clipboard button. No canvas, no external deps beyond React 18 and Tailwind CSS 3. Worth noting: HSL is a far more intuitive color model than RGB for humans — you can describe "a muted teal" in HSL in a way that maps to how designers actually think.

Quick aside: if you need to match colors to a specific visual style — say, the right palette for glassmorphism components or a cyberpunk UI — having fine-grained HSL control is way more useful than clicking around a 2D color square.

HSL vs RGB vs Hex: Pick the Right Model

HSL stands for Hue, Saturation, Lightness. Hue is a degree on a 360° color wheel — 0° is red, 120° is green, 240° is blue. Saturation is 0–100%, where 0% is gray and 100% is fully vivid. Lightness is also 0–100%, where 0% is black and 100% is white. That means hsl(200, 80%, 50%) is a saturated sky blue and you know that just by reading the numbers. Try doing that with #2E8BC0.

RGB and hex are what browsers actually render, so you'll always convert at the boundary — when you submit a form, write to localStorage, or call a CSS custom property. The conversion is straightforward math and you only need it in one place. Here's the function you'll use throughout this article: ``ts // utils/colorConvert.ts export function hslToHex(h: number, s: number, l: number): string { s /= 100; l /= 100; const a = s * Math.min(l, 1 - l); const f = (n: number) => { const k = (n + h / 30) % 12; const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1); return Math.round(255 * color) .toString(16) .padStart(2, '0'); }; return #${f(0)}${f(8)}${f(4)}; } export function hexToHsl(hex: string): [number, number, number] { const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); if (!result) return [0, 0, 50]; let r = parseInt(result[1], 16) / 255; let g = parseInt(result[2], 16) / 255; let b = parseInt(result[3], 16) / 255; const max = Math.max(r, g, b), min = Math.min(r, g, b); let h = 0, s = 0; const l = (max + min) / 2; if (max !== min) { const d = max - min; s = l > 0.5 ? d / (2 - max - min) : d / (max + min); switch (max) { case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break; case g: h = ((b - r) / d + 2) / 6; break; case b: h = ((r - g) / d + 4) / 6; break; } } return [Math.round(h * 360), Math.round(s * 100), Math.round(l * 100)]; } ``

Honestly, the hslToHex formula looks scarier than it is. It's the standard CSS Color 4 algorithm and it handles the full 0–360 hue range without edge cases. Paste it once, test it, forget it. The hexToHsl reverse path matters when users paste a hex value directly into your input — you need to sync the sliders back to match.

Building the HSL Slider Component

The hue slider is a <input type="range"> skinned with a gradient background. The trick: the track itself IS the color data. You don't need a canvas or SVG — a CSS gradient from red through the spectrum back to red is enough. Here's the slider atom: ``tsx // components/HueSlider.tsx interface HueSliderProps { hue: number; onChange: (hue: number) => void; } export function HueSlider({ hue, onChange }: HueSliderProps) { return ( <div className="relative h-4 rounded-full overflow-hidden"> <div className="absolute inset-0 rounded-full" style={{ background: 'linear-gradient(to right, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%)', }} /> <input type="range" min={0} max={360} value={hue} onChange={(e) => onChange(Number(e.target.value))} className="relative w-full h-full opacity-0 cursor-pointer" style={{ WebkitAppearance: 'none' }} /> </div> ); } ``

The opacity-0 on the actual range input is intentional — you're keeping the native drag behavior (including keyboard support, which matters for accessibility) while rendering the visual track yourself. The thumb is invisible here, so you'll want to add a positioned indicator. A 20px wide, 20px tall white circle with a box-shadow: 0 0 0 2px rgba(0,0,0,0.3) works perfectly as a draggable thumb that stays visible on any hue.

For saturation and lightness, you can use the same pattern — two more range inputs with appropriate gradients. The saturation track goes from gray (hsl(H, 0%, 50%)) to the fully vivid color (hsl(H, 100%, 50%)), and the lightness track goes from black to white. Both gradients should update reactively when the hue changes, which is why you calculate them inline from props rather than hardcoding them.

One more thing — test with a keyboard at every step. Tab to the slider, hit arrow keys. Range inputs handle this natively with a 1-unit step, but if you want finer control (0.5° increments on hue, say), add a step="0.5" attribute. React 18 batches the state updates across all three sliders automatically, so you won't get tearing.

Hex Input + Copy Button: The Full Component

Here's the complete ColorPicker component wiring everything together. It owns HSL state internally, derives the hex display value, and exposes an onChange callback with the hex string — which is what your forms and CSS vars actually need: ``tsx // components/ColorPicker.tsx import { useState, useCallback } from 'react'; import { hslToHex, hexToHsl } from '../utils/colorConvert'; import { HueSlider } from './HueSlider'; interface ColorPickerProps { defaultColor?: string; onChange?: (hex: string) => void; } export function ColorPicker({ defaultColor = '#6366f1', onChange }: ColorPickerProps) { const [hsl, setHsl] = useState<[number, number, number]>(() => hexToHsl(defaultColor) ); const [hexInput, setHexInput] = useState(defaultColor); const [copied, setCopied] = useState(false); const [h, s, l] = hsl; const currentHex = hslToHex(h, s, l); const handleHslChange = useCallback( (newH: number, newS: number, newL: number) => { setHsl([newH, newS, newL]); const hex = hslToHex(newH, newS, newL); setHexInput(hex); onChange?.(hex); }, [onChange] ); const handleHexInput = (val: string) => { setHexInput(val); if (/^#[0-9a-f]{6}$/i.test(val)) { const newHsl = hexToHsl(val); setHsl(newHsl); onChange?.(val); } }; const handleCopy = async () => { await navigator.clipboard.writeText(currentHex); setCopied(true); setTimeout(() => setCopied(false), 1500); }; return ( <div className="w-64 rounded-2xl border border-white/10 bg-white/5 backdrop-blur-sm p-4 space-y-4"> {/* Preview swatch */} <div className="h-20 rounded-xl" style={{ background: currentHex }} /> {/* Hue slider */} <HueSlider hue={h} onChange={(newH) => handleHslChange(newH, s, l)} /> {/* Saturation */} <input type="range" min={0} max={100} value={s} onChange={(e) => handleHslChange(h, Number(e.target.value), l)} className="w-full accent-indigo-500" /> {/* Lightness */} <input type="range" min={0} max={100} value={l} onChange={(e) => handleHslChange(h, s, Number(e.target.value))} className="w-full accent-indigo-500" /> {/* Hex input + copy */} <div className="flex gap-2"> <input type="text" value={hexInput} onChange={(e) => handleHexInput(e.target.value)} maxLength={7} className="flex-1 rounded-lg bg-white/10 px-3 py-1.5 text-sm font-mono text-white focus:outline-none focus:ring-2 focus:ring-indigo-500" /> <button onClick={handleCopy} className="rounded-lg bg-indigo-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-indigo-500 transition-colors" > {copied ? 'Copied!' : 'Copy'} </button> </div> </div> ); } ``

That copied state and the 1500ms timeout is the oldest trick in the book, but it works. Users need feedback. A button that silently copies without changing label causes repeat clicks and confusion — don't skip it. You could animate the transition with a scale pulse using Tailwind's transition-transform class, but even the label swap alone is satisfying enough.

Notice the hex input only syncs the sliders when the regex /^#[0-9a-f]{6}$/i matches. This lets users type freely without the UI jumping mid-entry. If someone types #ff the sliders stay put; once they finish with a valid 6-digit hex, everything snaps into sync. That's the UX behavior you want in a real form context.

Worth noting: the component uses accent-indigo-500 on the plain saturation/lightness sliders. That's a Tailwind 3.1 utility that tints the native range thumb and track with your brand color in one line — far less ceremony than the full custom-thumb treatment, and fine for secondary sliders where you want function over form.

Styling the Picker to Match Your Design System

The component above ships with a glassmorphic shell (bg-white/5 backdrop-blur-sm). That's a deliberate choice — color pickers typically float over content, like a popover, so the translucent background helps them sit naturally over whatever's underneath. If you're building a lighter-themed app, swap to bg-white border border-gray-200 shadow-lg and you're done. The internal logic doesn't care.

Tailwind's accent-* utilities handle the stock sliders fine for most use cases, but the hue slider genuinely needs custom styling — no accent color maps to a rainbow gradient. The pattern from the earlier HueSlider component (opacity-0 input over a div with a CSS gradient) is the most compatible approach in 2026 for getting a cross-browser gradient track. Trying to style the ::-webkit-slider-runnable-track pseudo-element directly is a compatibility minefield you don't want to enter.

If you're building this for a dark design system — say, a cyberpunk or neobrutalism component library — swap the swatch border to border-4 border-black and use a monospace font for the hex input. The copy button looks great as a flat rectangle at 2px border with no border-radius. The component's visual personality should match your system; the logic underneath doesn't change at all.

Look, the 64px swatch height is not arbitrary. Below 48px users can't meaningfully read the color at a glance, especially for desaturated values where the difference between 45% and 50% lightness is nearly invisible at small sizes. Go larger if you have the space — 96px or even 128px feels luxurious and actually useful.

Wiring It Into a Form

The onChange prop emits a hex string, which maps directly to HTML color inputs, CSS custom properties, and most backend schemas. Plugging it into a React Hook Form setup takes about three lines: ``tsx import { Controller, useForm } from 'react-hook-form'; import { ColorPicker } from './components/ColorPicker'; type FormValues = { brandColor: string }; export function BrandSettingsForm() { const { control, handleSubmit } = useForm<FormValues>({ defaultValues: { brandColor: '#6366f1' }, }); return ( <form onSubmit={handleSubmit(console.log)} className="space-y-6"> <Controller name="brandColor" control={control} render={({ field }) => ( <ColorPicker defaultColor={field.value} onChange={field.onChange} /> )} /> <button type="submit">Save</button> </form> ); } ``

The Controller wrapper is the right call here because ColorPicker is an uncontrolled-style component that manages its own internal HSL state — it's not a plain controlled input. Controller bridges the gap: it feeds the current form value as defaultColor on mount and fires field.onChange every time the user picks a color. Validation works normally: add rules={{ pattern: /^#[0-9a-f]{6}$/i }} if you want RHF to reject invalid hex strings that slipped through.

One more thing — if your color picker lives inside a popover (Radix UI Popover, Headless UI Menu, whatever), make sure the portal renders outside the form DOM node or your form submission might close the popover before the color is committed. Use Radix's Popover.Portal or Floating UI's FloatingPortal to mount it on document.body. Annoying edge case, but it bites everyone once.

Accessibility, Testing and Edge Cases

Every slider should have an aria-label. Screenreader users on macOS VoiceOver will announce the range value, but without a label they'll just say "slider" with no context. Add aria-label="Hue", aria-label="Saturation", and aria-label="Lightness" to each input. The copy button's dynamic label ('Copied!' vs 'Copy') should also trigger a live region announcement — wrap it in a <span aria-live="polite"> or add aria-pressed if you model it as a toggle.

Testing HSL math is table-driven and easy. You have a deterministic pure function — run it through 20-30 known values and you're done: ``ts // colorConvert.test.ts import { hslToHex, hexToHsl } from './colorConvert'; const cases: Array<[number, number, number, string]> = [ [0, 100, 50, '#ff0000'], [120, 100, 50, '#00ff00'], [240, 100, 50, '#0000ff'], [200, 80, 50, '#1a8fb3'], [0, 0, 50, '#808080'], ]; test.each(cases)('hsl(%i, %i%%, %i%%) → %s', (h, s, l, expected) => { expect(hslToHex(h, s, l)).toBe(expected); }); ``

Edge cases worth testing explicitly: hue = 0 and hue = 360 should produce the same color (they do in the formula above). Lightness = 0 should always return #000000 regardless of hue and saturation. Lightness = 100 should always return #ffffff. These are the values where the HSL → RGB math has subtle rounding traps and you want a test covering each one.

In practice, the hexToHsl round-trip isn't perfectly lossless at every value — you'll see 1° drift on some hues due to integer rounding. That's fine for a picker UI. If you're feeding the output into a tokenization system that needs exact round-trips, store the HSL triple as your source of truth and only compute hex for display and form submission.

FAQ

Can I use this color picker without Tailwind?

Yes. Replace every Tailwind class with inline styles or your own CSS. The logic is Tailwind-agnostic — Tailwind only handles the visual shell, not the HSL math or the slider behavior.

Why not just use react-colorful instead?

react-colorful is 1.8 kB and fine for most projects. Roll your own when you need full style control, a non-standard layout, or HSL sliders instead of an HSV canvas — which react-colorful doesn't expose cleanly.

How do I add alpha (opacity) support?

Add a fourth alpha number in state (0–100), a fourth slider with a gradient from transparent to the current color, and switch your output format from hex to hsla(H, S%, L%, A) or rgba(). The conversion utils above need an extra parameter but the structure stays the same.

Does the copy button work on HTTP (non-HTTPS) origins?

No — navigator.clipboard.writeText requires a secure context. On HTTP during local dev it'll throw. Wrap it in a try/catch and fall back to the deprecated document.execCommand('copy') if clipboard API is unavailable.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Range Slider in React: Single, Dual Handle, Custom TrackAdvanced Color Picker in React: HSLA, OKLCH, Hex and Eye DropperGradient Button in React + Tailwind: Hover, Focus and Active StatesButton Component Variants in Tailwind: Primary, Ghost, Icon, Loading