EmpireUI
Get Pro
← Blog9 min read#color picker#react#hsla

Advanced Color Picker in React: HSLA, OKLCH, Hex and Eye Dropper

Build a fully-featured React color picker supporting HSLA, OKLCH, hex, and the native EyeDropper API — with live conversion and a clean UI.

Developer coding a color picker component on a laptop screen

Why the Default Color Input Isn't Enough

The native <input type="color"> gives you a hex value and a platform-native picker. That's fine for a settings page where a user picks their avatar border once and forgets about it. It's not fine when you're building a design tool, a theme editor, or anything where color format matters.

Honestly, the thing that breaks most quickly is format diversity. Your design tokens live in OKLCH. Your CSS custom properties are in HSLA. Your Figma export spits hex. You need a picker that can speak all three — and convert between them without losing precision. A single <input type="color"> that gives you #ff6b35 and nothing else just doesn't cut it.

Worth noting: browser support for OKLCH in CSS hit 93%+ globally in 2024, but JavaScript color libraries were slow to follow. Most color picker packages as of 2025 still only output hex or HSL, not OKLCH. So if you need it, you're either rolling your own conversion math or choosing your dependency carefully.

This article builds a complete picker from scratch — sliders for hue, saturation, lightness, alpha, OKLCH channels, a hex input with live parsing, and the EyeDropper API for sampling any pixel on screen. You'll end up with a reusable <ColorPicker /> component you can drop into any React 18+ app. You can also check the Empire UI library for prebuilt color-aware components if you want to skip straight to production.

Color Space Math: What You Actually Need to Know

You don't need a degree in colorimetry. You need to understand three things: how hex, HSLA, and OKLCH relate, where they break down, and how to convert between them without visible banding.

Hex is sRGB. #ff6b35 is shorthand for rgb(255, 107, 53). Each channel is 0–255, giving you 16.7 million colors — enough for most UIs, not enough for HDR displays. HSLA sits on top of RGB; it's the same color space just represented as hue (0–360°), saturation (0–100%), lightness (0–100%), alpha (0–1). Changing saturation by 10% in HSLA feels perceptually inconsistent because the underlying model isn't perceptually uniform.

OKLCH fixes that. It's built on the Oklab color space (published 2020 by Björn Ottosson) and maps to human perception much more consistently. L is 0–1 lightness, C is chroma (0 to roughly 0.4 in practice), H is hue 0–360. The big win: rotating hue in OKLCH at constant L and C produces colors that actually look equally bright. No more weirdly dark yellows.

For conversion, the path is: hex → linear sRGB → XYZ-D65 → Oklab → OKLCH. In the other direction, reverse each step. This sounds painful but it's about 50 lines of pure math you write once. Here's the core of it:

// hex to linear sRGB channel
function toLinear(c: number): number {
  const s = c / 255;
  return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
}

// linear sRGB to Oklab
function rgbToOklab(r: number, g: number, b: number) {
  const lr = toLinear(r), lg = toLinear(g), lb = toLinear(b);
  const l = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb);
  const m = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb);
  const s = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb);
  return {
    L: 0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s,
    a: 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s,
    b: 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s,
  };
}

// Oklab to OKLCH
function oklabToOklch({ L, a, b }: { L: number; a: number; b: number }) {
  return {
    l: L,
    c: Math.sqrt(a * a + b * b),
    h: (Math.atan2(b, a) * 180) / Math.PI,
  };
}

Component Architecture

Keep it simple. One useColor hook holds all state and exposes update functions per channel. The visual layer is pure display — sliders, inputs, a preview swatch. No external state management needed.

The core type is a unified color object that holds every representation simultaneously. You update one, recompute the rest. This avoids the hellscape of keeping six separate state values in sync manually:

interface ColorState {
  hex: string;       // '#ff6b35'
  r: number; g: number; b: number;  // 0-255
  h: number; s: number; l: number; a: number; // HSLA
  oklch: { l: number; c: number; h: number };
}

The hook signature looks like this. It takes an initial hex string and returns the full state plus a set of typed updaters:

function useColor(initial = '#3b82f6') {
  const [color, setColor] = useState<ColorState>(() =>
    hexToColorState(initial)
  );

  const setHex = useCallback((hex: string) => {
    if (/^#[0-9a-f]{6}$/i.test(hex)) {
      setColor(hexToColorState(hex));
    }
  }, []);

  const setHsla = useCallback(
    (h: number, s: number, l: number, a: number) => {
      setColor(hslaToColorState(h, s, l, a));
    },
    []
  );

  const setOklch = useCallback(
    (l: number, c: number, h: number) => {
      setColor(oklchToColorState(l, c, h, color.a));
    },
    [color.a]
  );

  return { color, setHex, setHsla, setOklch };
}

That's the whole mental model. One source of truth, deterministic derivation. You won't be debugging why your hex input and your OKLCH sliders disagree at 3am.

Building the Slider Components

Each color channel gets a range slider with a gradient background that shows you the live range of that channel at the current values. That's what makes a color picker feel native versus feeling like a form field.

The hue slider background is always a full 360° rainbow — it doesn't change based on saturation. The saturation slider goes from the current hue at 0% saturation (grey) to the current hue at 100%. The lightness slider goes from black through the hue to white. Render these as linear-gradient on the slider track:

function HueSlider({ value, onChange }: { value: number; onChange: (h: number) => void }) {
  return (
    <div className="relative h-3 rounded-full" style={{
      background: 'linear-gradient(to right, #f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00)'
    }}>
      <input
        type="range"
        min={0} max={360} step={1}
        value={value}
        onChange={e => onChange(Number(e.target.value))}
        className="absolute inset-0 w-full opacity-0 cursor-pointer h-full"
      />
      <div
        className="absolute top-1/2 -translate-y-1/2 w-4 h-4 rounded-full border-2 border-white shadow-md pointer-events-none"
        style={{ left: `calc(${(value / 360) * 100}% - 8px)` }}
      />
    </div>
  );
}

That pattern — transparent range input layered over a styled div, with a custom thumb positioned via JS — works across all browsers from Chrome 90+. It gives you full control over the visual without fighting browser default styles. The 8px offset on the thumb is half of its 16px width; adjust if you go larger.

Quick aside: the alpha slider needs a checkerboard background under the gradient to signal transparency. Do it with a double background: first the gradient going from transparent to the current solid color, then a CSS checkerboard pattern underneath via background-image. Two lines of CSS, no canvas needed.

The EyeDropper API

This is the part most tutorials skip. The EyeDropper API lets users click anywhere on screen — outside the browser window, on their desktop, in another app — and sample that pixel's color. It landed in Chrome 95 and Edge 95 back in 2021. Firefox still doesn't support it as of mid-2026. Safari added it in 17.0.

The API is async and returns a hex color string. You call new EyeDropper().open(), the user gets a magnifier cursor, they click, and you get { sRGBHex: '#aabbcc' } back. That's it. Hook it up to your setHex and you're done:

async function pickFromScreen() {
  if (!('EyeDropper' in window)) {
    alert('EyeDropper not supported in this browser');
    return;
  }
  try {
    const result = await new (window as any).EyeDropper().open();
    setHex(result.sRGBHex);
  } catch {
    // User hit Escape — not an error worth surfacing
  }
}

// In JSX:
<button
  onClick={pickFromScreen}
  title="Pick color from screen"
  className="p-2 rounded-lg hover:bg-white/10"
>
  <Pipette className="w-4 h-4" />
</button>

In practice, the feature detection check matters. If you're building a design tool that targets Chrome-only (say, a Figma plugin or a Tauri app wrapping Chromium), you can skip the check. If you're shipping to the open web, show a tooltip that says 'Not supported in Firefox' rather than just silently hiding the button — users appreciate honesty.

Look, the EyeDropper is one of those browser APIs that feels like magic the first time you use it. Your users will love it. Add it. The 8 lines of code it takes are worth it every time.

Hex Input with Live Parsing

The hex input is trickier than it looks. You want the field to feel natural — the user can type #ff, pause, type 6b35, and only parse when they've got a valid 6-char hex. You don't want to reformat their input mid-type or throw errors on every keystroke.

The trick is controlled-uncontrolled duality. Keep a local draft string for what the user is typing. Only push it through setHex (and trigger full state recomputation) when it's valid. On blur, reset draft to the current canonical hex if the draft is invalid:

function HexInput({ value, onChange }: { value: string; onChange: (hex: string) => void }) {
  const [draft, setDraft] = useState(value);

  // Keep draft in sync when external value changes (e.g. from EyeDropper)
  useEffect(() => setDraft(value), [value]);

  function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
    const raw = e.target.value.startsWith('#') ? e.target.value : '#' + e.target.value;
    setDraft(raw);
    if (/^#[0-9a-f]{6}$/i.test(raw)) {
      onChange(raw);
    }
  }

  function handleBlur() {
    if (!/^#[0-9a-f]{6}$/i.test(draft)) {
      setDraft(value); // reset to last valid
    }
  }

  return (
    <input
      type="text"
      value={draft}
      onChange={handleChange}
      onBlur={handleBlur}
      maxLength={7}
      className="font-mono text-sm w-24 px-2 py-1 rounded border border-white/20 bg-white/5"
      placeholder="#000000"
    />
  );
}

One more thing — also accept 3-char hex shorthand. #f63 should expand to #ff6633. Add a normalization step: if it matches /^#[0-9a-f]{3}$/i, expand each character to two of itself before passing to onChange. Small touch, major quality-of-life improvement.

You can pair this component with Empire UI's glassmorphism generator if you're building a design tool that outputs CSS — users can pick colors here and pipe them straight into backdrop-filter values.

Putting It All Together

Here's the full ColorPicker component wiring everything up. It's ~80 lines of JSX and uses nothing outside React itself plus the conversion utilities you wrote above:

export function ColorPicker({
  value = '#3b82f6',
  onChange,
}: {
  value?: string;
  onChange?: (hex: string, oklch: string, hsla: string) => void;
}) {
  const { color, setHex, setHsla, setOklch } = useColor(value);

  useEffect(() => {
    if (onChange) {
      onChange(
        color.hex,
        `oklch(${color.oklch.l.toFixed(3)} ${color.oklch.c.toFixed(3)} ${color.oklch.h.toFixed(1)})`,
        `hsla(${color.h}, ${color.s}%, ${color.l}%, ${color.a})`
      );
    }
  }, [color]);

  return (
    <div className="w-64 p-4 rounded-2xl bg-neutral-900 border border-white/10 space-y-4">
      {/* Swatch */}
      <div className="h-12 rounded-xl" style={{ background: color.hex }} />

      {/* Hue, Saturation, Lightness, Alpha sliders */}
      <HueSlider value={color.h} onChange={h => setHsla(h, color.s, color.l, color.a)} />
      <SaturationSlider color={color} onChange={s => setHsla(color.h, s, color.l, color.a)} />
      <LightnessSlider color={color} onChange={l => setHsla(color.h, color.s, l, color.a)} />
      <AlphaSlider color={color} onChange={a => setHsla(color.h, color.s, color.l, a)} />

      {/* OKLCH sliders */}
      <OklchSliders color={color} onChange={setOklch} />

      {/* Hex + EyeDropper */}
      <div className="flex gap-2 items-center">
        <HexInput value={color.hex} onChange={setHex} />
        <EyeDropperButton onPick={setHex} />
      </div>
    </div>
  );
}

The onChange callback fires with all three formats simultaneously — hex, OKLCH, and HSLA. Whoever consumes this picker can grab whichever format their system needs without running conversions themselves. That's the API contract you want: the picker owns the color math, the consumer owns what to do with the output.

For styling, this sits nicely on top of any dark background. If you're going for glassmorphism on the panel itself, swap bg-neutral-900 for something like bg-white/5 backdrop-blur-xl and add border-white/10. The glassmorphism components page has the exact CSS values if you want to match that aesthetic.

That said, test this on a light background too. Sliders with semi-transparent tracks and white thumbs disappear on light backgrounds. You'll need a prefers-color-scheme aware thumb color or a hardcoded dark outline. Don't skip this.

FAQ

Which browsers support the EyeDropper API in 2026?

Chrome 95+, Edge 95+, and Safari 17+ all support it. Firefox still doesn't as of mid-2026. Always check with 'EyeDropper' in window before calling it — a silent missing button beats a runtime crash.

Why use OKLCH instead of just HSL for a color picker?

HSL isn't perceptually uniform — two colors at the same L value can look dramatically different in brightness. OKLCH maps much more closely to how your eyes actually perceive brightness and chroma, so hue rotation looks consistent. It matters especially in theme generators and gradient tools.

Can I use this color picker with Tailwind or CSS custom properties?

Yes. The component's onChange callback gives you all three formats as strings. Just take the HSLA or OKLCH string and set it on a CSS variable via document.documentElement.style.setProperty('--color-brand', hslString). Works with both Tailwind's JIT arbitrary values and vanilla CSS vars.

What's a good library if I don't want to write the conversion math myself?

culori is the best option — it's a tree-shakeable ES module package that supports every color space including OKLCH, with accurate conversions and a tiny footprint. npm install culori and import only converter plus the spaces you need.

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

Read next

Color Picker in React: HSL Slider, Hex Input and Copy ButtonDate Picker in React: react-day-picker v9 and Custom ApproachBuilding a Color System: Semantic Tokens, OKLCH and Dark ModeFramer Motion Advanced: Layout Animations, Shared Elements, useAnimate