# React Color Picker: HEX, RGB, HSL, Eyedropper and OKLCH

> Build a React color picker with HEX, RGB and HSL inputs, swatches, opacity and the EyeDropper API, then feed its value into OKLCH design tokens in Tailwind v4.

- Author: [Serban Rusu](https://wingo-ui.com/blog/authors/serban), Founder of Wingo UI
- Published: Oct 16, 2026
- Category: [Component guides](https://wingo-ui.com/blog/category/components)
- Reading time: 9 min
- Canonical: https://wingo-ui.com/blog/react-color-picker

## TL;DR

A React color picker needs a saturation and brightness square with a hue bar, HEX, RGB and HSL text fields, preset swatches, full keyboard support and a phone layout, plus the EyeDropper API where Chrome and Edge offer it. Store the value as a hex string and derive OKLCH in CSS with relative color syntax, because the picker works in sRGB. Wingo UI's Color Picker (Pro) ships all of this, with separate live and commit callbacks and a bottom sheet with a Done button on phones.

A React color picker looks like a weekend task until you list what users expect: a square to drag in, a hue bar, a field where a designer pastes `#FC4C01`, RGB or HSL channels for the developer who thinks in numbers, brand swatches, opacity, a pipette that grabs a color from the logo, keyboard support, and something usable on a phone. Then the value has to land in a design system that, in Tailwind CSS v4, is written in OKLCH. This guide covers each part with the [Color Picker](https://wingo-ui.com/components/color-picker) from Wingo UI. We build that library, so we are not neutral. Outside facts link to their sources and are dated.

## What should a React color picker include?

A complete picker has a saturation and brightness square, a hue bar, text fields in HEX, RGB and HSL, preset swatches, keyboard control of every surface and a phone layout. Opacity and an eyedropper are the two optional parts. Small libraries such as [react-colorful](https://github.com/omgovich/react-colorful) (3.1 KB gzipped and no dependencies, per its README in October 2026) give you the drag surfaces and a hex input, and you build the rest. Here is the checklist either way:

- **A saturation and brightness square plus a hue bar.** This is the HSV model, the fastest way to explore.
- **Text fields in HEX, RGB and HSL.** People paste values from Figma, a brand guide or DevTools, so every React color picker input should accept pasted text and reject garbage without losing the last good color.
- **Swatches**, because most real choices are "one of our eight brand colors".
- **Opacity** for shadows and overlays, off by default.
- **An eyedropper** where the browser has one.
- **Keyboard support** for the square and the bars, not only the text fields.
- **A phone layout**, since a 288px popover is hard to drag in with a thumb.

## Does shadcn/ui have a color picker?

No. As of October 16, 2026 the [shadcn/ui components page](https://ui.shadcn.com/docs/components) lists Popover, Slider, Input and Toggle Group, but no color picker, and suggests its registry directory for community components. A shadcn color picker is therefore a composition: a Popover, sliders for hue and alpha, a custom 2D area (Radix has no 2D slider), an input with your own parsing, and swatch buttons.

The Wingo UI component is built the same way, on Radix Popover and Radix Slider, and installs as source code with a shadcn-style CLI, so the files are yours to read and edit:

```bash
npx wingo-ui@latest add color-picker
```

The command also pulls in what it imports: [Field](https://wingo-ui.com/components/field) for the label and error anatomy, [Drawer](https://wingo-ui.com/components/drawer) for the phone sheet, [Segmented Control](https://wingo-ui.com/components/segmented-control) for the HEX / RGB / HSL switch, Button, and the small helpers it shares with the rest of the library (controllable state, media queries, motion presets and `UIProvider` defaults).

## How do you use the Color Picker in React?

Render `ColorPicker` with a label and a value: it shows a field, and clicking it opens a panel with the square, the hue bar, the format switch, the inputs and 16 default swatches.

```tsx
"use client"

import { useState } from "react"
import { ColorPicker } from "@/components/ui/color-picker"

export function StoreColor({ onSave }: { onSave: (color: string) => void }) {
  const [color, setColor] = useState("#FC4C01")

  return (
    <ColorPicker
      label="Primary color"
      description="The store's buttons and links use this color."
      value={color}
      onValueChange={setColor}
      onValueCommit={onSave}
      name="primary_color"
    />
  )
}
```

Two callbacks matter here. `onValueChange` fires live while you drag, at most once per animation frame, so a preview can follow the thumb. `onValueCommit` fires when the change is final: pointer up, Enter or blur in a text field, a swatch tap, an eyedropper pick, a key in the square, or the phone sheet's Done button. Save in the second one, or a single drag sends dozens of requests. The `name` prop renders a hidden input, so a native form or a server action reads the color with `formData.get("primary_color")`.

Try it below: drag in the square, then switch to RGB and type a value. Text that is not a color turns the field red and goes back to the last good color.

> Live demo (Color Picker): Drag in the square and the hue bar, then switch to RGB or HSL and type a value: it applies on Enter, and the storefront preview follows. Try it at [Color Picker](https://wingo-ui.com/components/color-picker) and install it with `npx wingo-ui@latest add color-picker`.

## How do the HEX, RGB and HSL inputs behave?

The `format` prop (`"hex"`, `"rgb"` or `"hsl"`, default `"hex"`) sets the output string and the fields the panel shows first. The switch inside the panel only changes which fields are visible, so a user can type RGB channels while your state keeps receiving hex. The output shapes are fixed: `"#FC4C01"`, `"rgb(252, 76, 1)"` and `"hsl(18, 99%, 50%)"`.

A few details save support tickets:

- A 3-digit hex expands, so typing `F60` gives `#FF6600`. The `#` is optional.
- ArrowUp and ArrowDown in a number field step by 1, Shift by 10.
- `defaultValue` and `value` accept any color the parser reads: hex with 3, 4, 6 or 8 digits, `rgb()` / `rgba()`, `hsl()` / `hsla()` with commas or spaces, and the 16 basic CSS names. The parser uses no canvas, so it also runs on the server.

The same conversions are exported for your own code. `parseColor(text)` returns an `Hsva` or `null`, and `formatColor(hsva, format, alpha)` writes it back:

```ts
import { formatColor, parseColor } from "@/components/ui/color-picker"

const parsed = parseColor("rgb(252 76 1)")
const hex = parsed ? formatColor(parsed, "hex") : null // "#FC4C01"
```

## How do you add opacity and brand swatches?

Turn on `alpha` for values that need transparency. It adds an opacity bar and field. Hex becomes `#RRGGBBAA` below 100%, and rgb and hsl always become `rgba()` / `hsla()`, so the string keeps one shape while the slider moves.

> Live demo (Color Picker): Drag the opacity bar: the format is rgb with alpha, so the value becomes rgba() and the checkerboard shows through the swatch. Try it at [Color Picker](https://wingo-ui.com/components/color-picker) and install it with `npx wingo-ui@latest add color-picker`.

For a brand palette, pass `swatches`. The default is the exported `COLOR_PICKER_SWATCHES` (16 colors, neutrals then red through pink), so you can extend it or replace it, and `[]` hides the row. With `showInputs={false}` the text fields, format switch and eyedropper disappear, which leaves the square, the hue bar and your swatches:

```tsx
import { COLOR_PICKER_SWATCHES, ColorPicker } from "@/components/ui/color-picker"

const BRAND = ["#3B2A20", "#6F4E37", "#A67B5B", "#2F5D50", "#D9822B"]

export function LabelColors() {
  return (
    <>
      <ColorPicker label="Label color" swatches={BRAND} showInputs={false} radius="full" />
      <ColorPicker label="Highlight" swatches={[...BRAND, ...COLOR_PICKER_SWATCHES]} />
    </>
  )
}
```

The swatches are a radio group: one tab stop, arrow keys to move (ArrowUp and ArrowDown jump a row of 8), and a ring that slides to the selected color.

## How does the EyeDropper API work in React?

The [EyeDropper API](https://developer.mozilla.org/en-US/docs/Web/API/EyeDropper_API) opens a system pipette: `new EyeDropper().open()` resolves to `{ sRGBHex }` when the user clicks a pixel. MDN marks it experimental and limits it to secure contexts. Per [MDN's open() reference](https://developer.mozilla.org/en-US/docs/Web/API/EyeDropper/open), the promise rejects with `AbortError` on Escape or an aborted signal, `NotAllowedError` when it is not called from a user gesture, and `InvalidStateError` when another eyedropper is already open. As of October 2026, [caniuse](https://caniuse.com/mdn-api_eyedropper) lists support in desktop Chrome and Edge 95+ and Opera 81+, and none in Safari, Firefox or Chrome for Android.

If you build a React color picker with eyedropper support yourself, three things go wrong often:

1. Reading `window.EyeDropper` during render, which crashes on the server or causes a hydration mismatch. Detect it after mount.
2. Showing a dead button in Safari. Hide it where the API is missing.
3. Leaving the system pipette open when your popover closes. Pass an `AbortSignal` to `open()` and abort on unmount.

A minimal version that avoids all three:

```tsx
"use client"

import { useEffect, useRef, useSyncExternalStore } from "react"

type EyeDropperCtor = new () => { open: (o?: { signal?: AbortSignal }) => Promise<{ sRGBHex: string }> }

const subscribe = () => () => {}

export function EyeDropperButton({ onPick }: { onPick: (hex: string) => void }) {
  // false on the server and during hydration, the real answer after
  const supported = useSyncExternalStore(subscribe, () => "EyeDropper" in window, () => false)
  const abort = useRef<AbortController | null>(null)
  useEffect(() => () => abort.current?.abort(), [])

  if (!supported) return null

  const pick = async () => {
    if (abort.current) return // a second open() would throw InvalidStateError
    const controller = new AbortController()
    abort.current = controller
    try {
      const EyeDropper = (window as unknown as { EyeDropper: EyeDropperCtor }).EyeDropper
      const { sRGBHex } = await new EyeDropper().open({ signal: controller.signal })
      onPick(sRGBHex)
    } catch {
      // AbortError: Escape or unmount
    } finally {
      abort.current = null
    }
  }

  return (
    <button type="button" onClick={pick}>
      Pick from screen
    </button>
  )
}
```

The Wingo UI picker does the same internally: the pipette renders only where `window.EyeDropper` exists, closing the panel aborts a pick in progress, and the popover ignores outside clicks while the system pipette is open, so the click that picks a pixel does not dismiss the panel. Turn it off with `showEyeDropper={false}`.

## How does a React color picker fit OKLCH tokens in Tailwind v4?

Store the user's choice as hex and let CSS derive OKLCH from it. Tailwind v4's default palette is written in OKLCH (`--color-red-500: oklch(63.7% 0.237 25.331)` in the [colors docs](https://tailwindcss.com/docs/colors), checked October 2026), while the picker works in sRGB and its parser does not read `oklch()` strings. The two halves split cleanly:

- **Hex is the storage format.** It is what the picker emits, what people paste, and what the EyeDropper returns (`sRGBHex`).
- **OKLCH is derived in CSS.** Relative color syntax converts at paint time: `oklch(from #FC4C01 l c h)` is that color in OKLCH, and you can change one channel, such as the lightness, while keeping the others. [Caniuse](https://caniuse.com/css-relative-colors) lists full support in Chrome and Edge 131, Safari 18 and Firefox 133 as of October 2026.

Wingo UI's free `colorVars` helper in `@/lib/tones` does exactly this, and Button's `color` prop runs through it. It turns one string into five tone variables and picks black or white text from the OKLCH lightness:

```ts
"--tone-foreground": `oklch(from ${color} clamp(0, (0.7 - l) * 1000, 1) 0 0)`,
"--tone-soft": `color-mix(in oklab, ${color} 14%, transparent)`,
```

So a user-picked color can restyle one button with `<Button color={color}>`, or the whole brand tone at runtime, without a build step:

```tsx
import type { CSSProperties, ReactNode } from "react"

export function BrandScope({ color, children }: { color: string; children: ReactNode }) {
  const style = {
    "--brand": color,
    "--brand-foreground": `oklch(from ${color} clamp(0, (0.7 - l) * 1000, 1) 0 0)`,
    "--brand-soft": `color-mix(in oklab, ${color} 14%, transparent)`,
    "--brand-soft-foreground": `color-mix(in oklab, ${color} 72%, var(--foreground))`,
  } as CSSProperties

  return <div style={style}>{children}</div>
}
```

Every `tone="brand"` component and every `bg-brand` utility inside the wrapper picks up the new color. One condition: the override only reaches utilities when the theme maps them with `@theme inline { --color-brand: var(--brand); }`, which Wingo UI's `globals.css` does. With plain `@theme`, `var(--brand)` resolves once on `:root`, and a wrapper that redefines `--brand` changes nothing for `bg-brand`.

If you want a React color picker OKLCH readout, or need the numbers for a token file, convert the picker's RGB in JavaScript with [Björn Ottosson's OKLab matrices](https://bottosson.github.io/posts/oklab/):

```ts
import { hsvaToRgba, parseColor } from "@/components/ui/color-picker"

const linear = (c: number) => {
  const v = c / 255
  return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4
}

export function toOklch(color: string) {
  const hsva = parseColor(color)
  if (!hsva) return null
  const { r, g, b } = hsvaToRgba(hsva)
  const [lr, lg, lb] = [r, g, b].map(linear)
  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)
  const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s
  const A = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s
  const B = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s
  const C = Math.hypot(A, B)
  const H = C < 0.0001 ? 0 : ((Math.atan2(B, A) * 180) / Math.PI + 360) % 360
  return `oklch(${L.toFixed(4)} ${C.toFixed(4)} ${H.toFixed(2)})`
}

toOklch("#FC4C01") // "oklch(0.6617 0.2215 36.93)"
```

One honest limit: hex can only describe sRGB. The [Tailwind v4 release post](https://tailwindcss.com/blog/tailwindcss-v4) says the OKLCH palette uses a wider gamut to make colors more vivid than sRGB allowed, so picking one of those colors on a P3 screen gives you its nearest sRGB hex, not the original value. Keep wide-gamut tokens hand-written in CSS and use the picker for user choices. For how the token layers fit together, read [Tailwind v4 design tokens](https://wingo-ui.com/blog/tailwind-v4-design-tokens).

## Is the color picker accessible with a keyboard?

Yes. The square and the bars are custom surfaces with `role="slider"`, and each takes keys:

- Enter, Space or ArrowDown on the trigger opens the panel with focus in the square.
- In the square, ArrowLeft and ArrowRight change saturation by 1%, ArrowUp and ArrowDown brightness (Shift for 10%), PageUp and PageDown step brightness by 10%, and Home and End jump to no or full saturation.
- The hue and opacity bars take the arrow keys, Home and End.
- Escape closes the panel and returns focus to the trigger. If a text field has an unapplied edit, the first Escape reverts the edit and keeps the panel open.
- The trigger reads its label with the value ("Primary color, #FC4C01"), and every spoken string can be overridden through `labels`.

## How does a color picker work on a phone?

Below 768px the panel opens in a bottom sheet instead of a popover: full width, a taller square, 28px thumbs and larger swatches. Changes in the sheet are a draft until the user taps Done, and dragging the sheet away keeps the old color. `onValueChange` fires only on Done there, so a live preview elsewhere on the page will not follow the drag on phones. Set `responsive={false}` if you want the popover on phones too. The pattern and its trade-offs are covered in [the React bottom sheet for the web](https://wingo-ui.com/blog/react-bottom-sheet-for-the-web).

## Can you put the panel in your own popover or page?

Yes. Two options cover most layouts:

- `inline` renders the panel in place, with no trigger and no popover, for a settings page where changes apply at once.
- `ColorPickerPanel` is the panel alone, with the same `value`, `format`, `alpha`, `swatches` and `labels` props, for your own surface. Here it sits in the free [Popover](https://wingo-ui.com/components/popover) behind a toolbar button:

```tsx
import { Palette } from "lucide-react"
import { Button } from "@/components/ui/button"
import { ColorPickerPanel } from "@/components/ui/color-picker"
import { Popover } from "@/components/ui/popover"

export function TextColorButton({ color, onChange }: { color: string; onChange: (c: string) => void }) {
  return (
    <Popover
      trigger={
        <Button variant="ghost" size="icon-sm" aria-label="Text color">
          <Palette />
        </Button>
      }
      align="start"
      sheetTitle="Text color"
    >
      <ColorPickerPanel value={color} onValueChange={onChange} showFormatSwitch={false} />
    </Popover>
  )
}
```

For a compact toolbar without your own popover, `trigger="swatch"` turns the field into a round button sized like an icon button.

## What breaks when you store picker colors?

Three mistakes usually surface only after launch, once real data is saved:

- **Round-tripping through HSL.** `hsl()` output rounds every channel to whole numbers, so `#FC4C01` becomes `hsl(18, 99%, 50%)`, and parsing that back gives `#FE4D01`. Store hex or rgb when the exact value matters, and use hsl only for display.
- **A controlled value in another format.** Pass `"rgb(252 76 1)"` with `format="hex"` and the picker shows it as given, then emits hex on the next change. Normalize once with `parseColor` and `formatColor` if your database needs one shape.
- **A brand color with poor contrast.** `colorVars` switches to black text only above OKLCH lightness 0.7. `#FC4C01` sits at 0.66, so it gets white text at a 3.4:1 contrast ratio, below the 4.5:1 that WCAG AA asks for body text (black would reach 6.2:1). Check the contrast in `onValueCommit` before saving, and show the problem through the `error` prop, which replaces the description and marks the field invalid.

## Where should you start?

Install the picker and wire `onValueCommit` to the place you save the theme:

```bash
npx wingo-ui@latest add color-picker
```

The Color Picker is part of [Wingo UI Pro](https://wingo-ui.com/pricing); the Popover, Field and Drawer it builds on are free. For the rest of the form anatomy (labels, errors, validation with zod), see the [guide to React form components](https://wingo-ui.com/blog/react-form-components-guide), and the [Component guides](https://wingo-ui.com/blog/category/components) category collects every post like this one.

## Components in this post

- [Color Picker](https://wingo-ui.com/components/color-picker) (Pro): A React color picker with a saturation and brightness square, hue and opacity sliders, HEX / RGB / HSL inputs, swatches and an eyedropper. Install: `npx wingo-ui@latest add color-picker`
- [Popover](https://wingo-ui.com/components/popover) (Free): A React popover for rich content anchored to a trigger, like a quick filter, form or share panel, that becomes a bottom sheet on phones. Install: `npx wingo-ui@latest add popover`

## FAQ

### Does shadcn/ui have a color picker?

No. As of October 2026 the shadcn/ui components page lists Popover, Slider and Input but no color picker, and points to its registry directory for community components. You either compose one from those parts or install a ready component from a registry.

### Which browsers support the EyeDropper API?

Desktop Chrome and Edge 95+ and Opera 81+, according to caniuse in October 2026. Safari, Firefox and Chrome for Android do not support it, so feature-detect window.EyeDropper after mount and hide the button where it is missing.

### Can a React color picker output OKLCH?

Wingo UI's picker emits hex, rgb() or hsl() because it works in sRGB, and react-colorful's pickers cover HEX, RGB, HSL and HSV only, per its README in October 2026. Convert in CSS with oklch(from #FC4C01 l c h), or in JavaScript with Björn Ottosson's OKLab formulas, and keep wide-gamut tokens hand-written in CSS.

### How do I save the color only when the user is done dragging?

Use two callbacks: onValueChange for the live preview and onValueCommit for saving. Wingo UI's Color Picker calls onValueCommit on pointer up, Enter or blur in a field, a swatch, an eyedropper pick, a key in the square or the phone sheet's Done button.

### Is the Wingo UI Color Picker free?

No, the Color Picker is part of Wingo UI Pro. The Popover, Field, Drawer, Segmented Control and Button it builds on are free.

---

Source: https://wingo-ui.com/blog/react-color-picker
