Color Picker

Saturation area, hue and alpha sliders, eyedropper, hex input and an oklch readout.

@drift-ui/color-pickerRegistry JSON
On this page

Installation

The CLI also installs the npm packages and registry items it builds on. Register the namespace once first with https://drift-ui.dev/r/{style}/{name}.json. The CLI fills in {style} from your components.json and installs the Base UI or Radix build to match. See setup.

API reference

ColorPicker

A trigger button that opens a saturation and brightness area, hue and alpha sliders, a hex field, an oklch readout and, where the browser has it, the EyeDropper.

Prop
valuestringControlled hex color, "#rrggbb" or "#rrggbbaa".
defaultValuestring= "#000000"Initial value when uncontrolled.
onValueChange(hex: string) => voidCalled with the new hex color.
showAlphaboolean= trueShow the opacity slider.
swatchesstring[]Preset hex colors shown as buttons.
variantButton variant= "outline"Style of the trigger button.
sizeButton sizeSize of the trigger button.

Other props go to the trigger <button>.

Source

components/ui/color-picker.tsx
"use client"

import * as React from "react"
import { PipetteIcon } from "lucide-react"

import { cn } from "@/lib/utils"
import {
  formatHex,
  formatOklch,
  parseHex,
  type Hsva,
} from "@/lib/color"
import { useControllableState } from "@/hooks/use-controllable-state"
import { Button, buttonVariants } from "@/components/ui/button"
import {
  AlphaSlider,
  ColorArea,
  ColorSwatch,
  HueSlider,
} from "@/components/ui/color-picker-controls"
import { Input } from "@/components/ui/input"
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/popover"

const BLACK: Hsva = { h: 0, s: 0, v: 0, a: 1 }

type EyeDropperConstructor = new () => {
  open: () => Promise<{ sRGBHex: string }>
}

function subscribeToNothing() {
  return () => {}
}

function readEyeDropper() {
  return (window as { EyeDropper?: EyeDropperConstructor }).EyeDropper ?? null
}

function useEyeDropper() {
  return React.useSyncExternalStore(
    subscribeToNothing,
    readEyeDropper,
    () => null
  )
}

function HexInput({
  hex,
  onCommit,
}: {
  hex: string
  onCommit: (hex: string) => void
}) {
  const [draft, setDraft] = React.useState(hex)

  return (
    <Input
      aria-label="Hex color"
      spellCheck={false}
      value={draft}
      aria-invalid={parseHex(draft) ? undefined : true}
      onChange={(event) => setDraft(event.target.value)}
      onBlur={() => onCommit(draft)}
      onKeyDown={(event) => {
        if (event.key === "Enter") onCommit(draft)
      }}
      className="h-8 font-mono text-xs"
    />
  )
}

function ColorPicker({
  value,
  defaultValue = "#000000",
  onValueChange,
  showAlpha = true,
  swatches,
  variant = "outline",
  size,
  className,
  ...props
}: Omit<React.ComponentProps<"button">, "value" | "defaultValue" | "onChange"> &
  Pick<React.ComponentProps<typeof Button>, "variant" | "size"> & {
    value?: string
    defaultValue?: string
    onValueChange?: (hex: string) => void
    showAlpha?: boolean
    swatches?: string[]
  }) {
  const EyeDropper = useEyeDropper()
  const { value: hex, setValue: setHex } = useControllableState({
    value,
    defaultValue,
    onChange: onValueChange,
  })
  const [hsva, setHsva] = React.useState(() => parseHex(hex) ?? BLACK)
  const [syncedHex, setSyncedHex] = React.useState(hex)

  if (hex !== syncedHex) {
    setSyncedHex(hex)
    if (hex !== formatHex(hsva)) setHsva(parseHex(hex) ?? hsva)
  }

  function updateHsva(next: Hsva) {
    const normalized = showAlpha ? next : { ...next, a: 1 }
    const nextHex = formatHex(normalized)
    setHsva(normalized)
    setSyncedHex(nextHex)
    setHex(nextHex)
  }

  function commitHex(candidate: string) {
    const parsed = parseHex(candidate)
    if (parsed) updateHsva(parsed)
  }

  async function pickFromScreen() {
    if (!EyeDropper) return
    try {
      const { sRGBHex } = await new EyeDropper().open()
      const picked = parseHex(sRGBHex)
      if (picked) updateHsva({ ...picked, a: hsva.a })
    } catch {
      return
    }
  }

  return (
    <Popover>
      <PopoverTrigger
        data-slot="color-picker-trigger"
        className={cn(
          buttonVariants({ variant, size }),
          "justify-start gap-2 font-mono text-xs",
          className
        )}
        {...props}
      >
        <ColorSwatch color={hex} />
        {hex}
      </PopoverTrigger>
      <PopoverContent
        data-slot="color-picker-content"
        align="start"
        className="flex w-64 flex-col gap-3 p-3"
      >
        <ColorArea hsva={hsva} onHsvaChange={updateHsva} />
        <div className="flex items-center gap-3">
          {EyeDropper && (
            <Button
              variant="ghost"
              size="icon"
              aria-label="Pick a color from the screen"
              onClick={pickFromScreen}
              className="size-8"
            >
              <PipetteIcon />
            </Button>
          )}
          <div className="flex flex-1 flex-col gap-3">
            <HueSlider hsva={hsva} onHsvaChange={updateHsva} />
            {showAlpha && <AlphaSlider hsva={hsva} onHsvaChange={updateHsva} />}
          </div>
        </div>
        <HexInput key={hex} hex={hex} onCommit={commitHex} />
        <output className="truncate font-mono text-[11px] text-muted-foreground select-all">
          {formatOklch(hsva)}
        </output>
        {swatches && (
          <div className="grid grid-cols-8 gap-1.5">
            {swatches.map((swatch) => (
              <button
                key={swatch}
                type="button"
                aria-label={`Use ${swatch}`}
                onClick={() => commitHex(swatch)}
                className="rounded-sm outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
              >
                <ColorSwatch color={swatch} className="size-full" />
              </button>
            ))}
          </div>
        )}
      </PopoverContent>
    </Popover>
  )
}

export { ColorPicker }