Inputs

Color Picker

A hex-driven color picker — saturation/value area, hue and alpha sliders, a format-aware text field, quick swatches, and an optional native eyedropper.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { Check, Copy, CopyX, Pipette } from "lucide-react"
import { cn } from "@/lib/utils"

// ---------------------------------------------------------------------------
// Color math — pure functions, no external color libraries. Internally the
// picker works in HSV (hue/saturation/value) because that maps directly onto
// the 2D area + hue slider UI; RGB is the interchange format for hex/rgb();
// HSL is derived only when the text field needs to display/parse it.
// ---------------------------------------------------------------------------

type Rgba = { r: number; g: number; b: number; a: number } // r/g/b: 0-255 ints, a: 0-1

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/color-picker.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "ColorPicker" component (lucide-react
Check/Copy/CopyX/Pipette icons, no color-math libraries — HSV math is
hand-written).

Contract
- Export a forwardRef div. Props: value (string, controlled hex — `#rgb`,
  `#rrggbb`, or `#rrggbbaa`), onValueChange ((hex: string) => void), format
  ("hex" | "rgb" | "hsl", default "hex" — only affects the text field's
  display/parse echo, the value contract itself is always hex), swatches
  (string[] of hex colors, sensible 8-color default, pass [] to hide the
  row), alpha (boolean, default false — shows the opacity slider and allows
  alpha in the emitted hex/rgb()/hsl() text), disabled (default false),
  label (accessible name for trigger + panel, default "Color", not rendered
  visibly), showEyedropper (boolean, default true, gated by capability
  detection — see below).

Behavior
- Trigger is a button showing a small checkerboard-backed swatch (so partial
  alpha is visible) plus the current value formatted per `format`. Clicking
  toggles an absolutely-positioned panel below it (role="dialog",
  aria-label). Outside pointerdown and Escape close it (Escape also returns
  focus to the trigger); listeners are added only while open and removed on
  close/unmount.
- Panel contains, top to bottom: a 2D saturation/value area (role="slider"
  on its thumb, aria-valuenow = saturation%, aria-valuetext describing both
  saturation and brightness; arrow keys nudge by 1, Shift+arrow by 10, no
  Home/End), a hue slider (0-360, role="slider", arrow keys ±1/±15 with
  Shift, Home/End jump to 0/360), an alpha slider shown only when `alpha` is
  true (0-100%, same keyboard scheme, track is a checkerboard with a
  transparent→opaque gradient of the current color on top), a text input, a
  copy button, an optional eyedropper button, and a swatch row.
- 2D area and both sliders use pointer events + setPointerCapture, with
  strict pointerId isolation: a shared "which control, which pointerId is
  dragging" ref means a second touch point landing on the same or a
  different control while one drag is in flight is ignored outright — only
  the pointer that started the drag can move or end it.
- Internally the color lives as HSV (h 0-360, s/v 0-100) plus alpha 0-1 —
  the natural representation for the 2D area + hue slider. It's re-derived
  from the `value` hex prop whenever that prop actually changes (not on
  every render), with one guard: if the newly-parsed color is pure black
  (v=0) or fully desaturated gray (s=0), the previous hue/saturation is kept
  instead of the arithmetic h=0 fallback, so dragging into a gray/black
  corner and back out doesn't snap the hue slider to red.
- Text field accepts hex, rgb()/rgba(), or hsl()/hsla() regardless of
  `format` — any of the three that parses is emitted immediately as you
  type. On blur, if the current text doesn't parse, it reverts to the
  last-committed value (reformatted per `format`) and sets aria-invalid;
  focusing the field again clears aria-invalid. It never throws on garbage
  input.
- Swatch click and a successful eyedropper pick both call the same commit
  path as dragging. Eyedropper button only renders when `showEyedropper` is
  true AND the browser exposes `window.EyeDropper` — detected once via
  useSyncExternalStore (never read directly during render), so server and
  first client render agree and there's no hydration mismatch.
- Copy button copies the current formatted string via the Clipboard API,
  shows a checkmark for 2s on success; if the API is unavailable or the
  write rejects, shows a distinct "failed" icon/aria-label instead of
  silently doing nothing.
- disabled: real HTML disabled on the trigger button (nothing downstream
  needs its own disabled branches since the panel can never open); if
  disabled flips true while the panel happens to be open, it force-closes.

Rendering & styling
- Chrome (borders, panel background, focus rings, text) uses semantic
  tokens only: border, bg-popover/bg-background, text-foreground,
  text-muted-foreground, ring-ring, border-destructive for the invalid
  state. The checkerboard (transparency chrome) is also token-based via
  repeating-conic-gradient + color-mix(in oklab, var(--muted-foreground)
  35%, transparent) — never a raw hex.
- The actual color swatches, the saturation-value area's hue-tinted
  background, the hue-spectrum gradient, and the alpha gradient are runtime
  color *data* (from props/state), so they're painted via inline style and
  are explicitly exempt from the token rule — same exemption this repo's
  Swatch Picker documents for its check mark.
- cn() merges className on the root. motion-reduce disables the swatch
  hover scale.

Customization levers
- Panel width/placement: change the `w-64`/`mt-2` panel classes, or swap the
  manual absolute-position panel for a floating-ui/Radix Popover if you need
  collision-aware placement.
- 2D area size/shape: aspect-square is a deliberate choice; make it a fixed
  height rectangle instead if you want a shorter panel.
- Default swatches: swap DEFAULT_SWATCHES for your brand palette.
- Alpha: flip the default to true if your product edits colors that always
  need opacity control.
- Add a "recent colors" row by lifting a small history array in the
  consumer and passing it through `swatches` alongside (or instead of) the
  fixed palette.

Concepts

  • HSV decode/re-encode — the picker's only source of truth is the value hex prop; internally it decodes to hue/saturation/value + alpha because that maps directly onto the 2D area and hue slider, then re-encodes to hex on every change.
  • Gray/black hue preservation — hue is mathematically undefined at zero saturation or zero value; the decode step keeps the previous hue/saturation in those cases so the hue slider doesn't visibly snap to red when you drag into a corner and back out.
  • pointerId isolation — each draggable (2D area, hue slider, alpha slider) tracks which single pointerId started its gesture; any other pointer's move/up events on that same control are ignored, so a second touch point can't hijack or prematurely end an in-progress drag.
  • Format is display-onlyformat changes how the text field reads and what syntax it echoes back; the value contract passed to onValueChange is always hex, regardless of format.
  • Text field rollback — typing is validated on every keystroke (hex, rgb(), or hsl() — whichever parses commits immediately); only on blur does an unparseable string get reverted, paired with aria-invalid so screen readers and sighted users both get a signal.
  • Capability-detected eyedropperwindow.EyeDropper is an experimental, not-universally-supported API; the button is present only when the browser exposes it, checked once via useSyncExternalStore so the server-rendered markup and the first client render never disagree.

On This Page