# Color Picker

> Color picker with a saturation/brightness area, hue bar, hex input and presets, with keyboard nudging.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/color-picker
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/color-picker.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

No extra dependencies.

## Usage

```tsx title="components/color-picker-demo.tsx"
"use client"

import * as React from "react"

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

export default function ColorPickerDemo() {
  const [color, setColor] = React.useState("#6366f1")
  return (
    <div className="flex flex-wrap items-center justify-center gap-6">
      <ColorPicker defaultValue="#6366f1" onValueChange={setColor} />
      <div className="flex flex-col items-center gap-3">
        <div className="size-28 rounded-3xl border shadow-sm transition-colors" style={{ backgroundColor: color }} />
        <code className="rounded bg-muted px-2 py-1 text-xs uppercase" data-testid="color">
          {color}
        </code>
      </div>
    </div>
  )
}
```

## Source

```tsx title="components/color-picker.tsx"
"use client"

import * as React from "react"

import { cn } from "@/lib/utils"

type Hsv = { h: number; s: number; v: number }

const clamp = (n: number, lo = 0, hi = 1) => Math.min(hi, Math.max(lo, n))

function hsvToHex({ h, s, v }: Hsv) {
  const f = (n: number) => {
    const k = (n + h / 60) % 6
    return v - v * s * Math.max(0, Math.min(k, 4 - k, 1))
  }
  return (
    "#" +
    [f(5), f(3), f(1)]
      .map((c) => Math.round(c * 255).toString(16).padStart(2, "0"))
      .join("")
  )
}

function hexToHsv(hex: string): Hsv | null {
  const match = /^#?([\da-f]{6})$/i.exec(hex.trim())
  if (!match) return null
  const n = parseInt(match[1], 16)
  const r = ((n >> 16) & 255) / 255
  const g = ((n >> 8) & 255) / 255
  const b = (n & 255) / 255
  const max = Math.max(r, g, b)
  const d = max - Math.min(r, g, b)
  let h = 0
  if (d !== 0) {
    if (max === r) h = ((g - b) / d) % 6
    else if (max === g) h = (b - r) / d + 2
    else h = (r - g) / d + 4
    h *= 60
    if (h < 0) h += 360
  }
  return { h, s: max === 0 ? 0 : d / max, v: max }
}

const DEFAULT_SWATCHES = ["#ef4444", "#f97316", "#eab308", "#22c55e", "#06b6d4", "#3b82f6", "#8b5cf6", "#ec4899", "#0f172a", "#ffffff"]

export interface ColorPickerProps extends Omit<React.ComponentProps<"div">, "onChange" | "defaultValue"> {
  defaultValue?: string
  onValueChange?: (hex: string) => void
  swatches?: string[]
}

export function ColorPicker({
  defaultValue = "#6366f1",
  onValueChange,
  swatches = DEFAULT_SWATCHES,
  className,
  ...props
}: ColorPickerProps) {
  const [hsv, setHsv] = React.useState<Hsv>(() => hexToHsv(defaultValue) ?? { h: 240, s: 0.6, v: 0.9 })
  const [draft, setDraft] = React.useState<string | null>(null)
  const hex = hsvToHex(hsv)

  const update = (next: Hsv) => {
    setHsv(next)
    setDraft(null)
    onValueChange?.(hsvToHex(next))
  }

  const pickArea = (event: React.PointerEvent<HTMLDivElement>) => {
    const rect = event.currentTarget.getBoundingClientRect()
    update({ ...hsv, s: clamp((event.clientX - rect.left) / rect.width), v: 1 - clamp((event.clientY - rect.top) / rect.height) })
  }

  const pickHue = (event: React.PointerEvent<HTMLDivElement>) => {
    const rect = event.currentTarget.getBoundingClientRect()
    update({ ...hsv, h: clamp((event.clientX - rect.left) / rect.width) * 360 })
  }

  const dragProps = (handler: (event: React.PointerEvent<HTMLDivElement>) => void) => ({
    onPointerDown: (event: React.PointerEvent<HTMLDivElement>) => {
      event.currentTarget.setPointerCapture(event.pointerId)
      event.currentTarget.focus()
      handler(event)
    },
    onPointerMove: (event: React.PointerEvent<HTMLDivElement>) => {
      if (event.currentTarget.hasPointerCapture(event.pointerId)) handler(event)
    },
  })

  const onAreaKey = (event: React.KeyboardEvent) => {
    const step = event.shiftKey ? 0.1 : 0.02
    const delta: Record<string, Partial<Hsv>> = {
      ArrowLeft: { s: clamp(hsv.s - step) },
      ArrowRight: { s: clamp(hsv.s + step) },
      ArrowUp: { v: clamp(hsv.v + step) },
      ArrowDown: { v: clamp(hsv.v - step) },
    }
    if (event.key in delta) {
      event.preventDefault()
      update({ ...hsv, ...delta[event.key] })
    }
  }

  const onHueKey = (event: React.KeyboardEvent) => {
    const step = event.shiftKey ? 20 : 4
    if (event.key === "ArrowLeft" || event.key === "ArrowDown") {
      event.preventDefault()
      update({ ...hsv, h: clamp(hsv.h - step, 0, 360) })
    } else if (event.key === "ArrowRight" || event.key === "ArrowUp") {
      event.preventDefault()
      update({ ...hsv, h: clamp(hsv.h + step, 0, 360) })
    }
  }

  const focusRing = "outline-none focus-visible:ring-4 focus-visible:ring-ring/40"

  return (
    <div {...props} className={cn("w-64 select-none rounded-2xl border bg-popover p-3 text-popover-foreground shadow-sm", className)}>
      <div
        role="slider"
        tabIndex={0}
        aria-label="Saturation and brightness"
        aria-valuetext={`Saturation ${Math.round(hsv.s * 100)}%, brightness ${Math.round(hsv.v * 100)}%`}
        aria-valuenow={Math.round(hsv.s * 100)}
        onKeyDown={onAreaKey}
        {...dragProps(pickArea)}
        className={cn("relative h-36 w-full cursor-crosshair touch-none rounded-lg", focusRing)}
        style={{
          backgroundColor: `hsl(${hsv.h} 100% 50%)`,
          backgroundImage: "linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, transparent)",
        }}
      >
        <span
          className="pointer-events-none absolute size-4 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-[0_0_0_1px_rgba(0,0,0,0.3)]"
          style={{ left: `${hsv.s * 100}%`, top: `${(1 - hsv.v) * 100}%`, backgroundColor: hex }}
        />
      </div>

      <div
        role="slider"
        tabIndex={0}
        aria-label="Hue"
        aria-valuemin={0}
        aria-valuemax={360}
        aria-valuenow={Math.round(hsv.h)}
        onKeyDown={onHueKey}
        {...dragProps(pickHue)}
        className={cn("relative mt-3 h-3 w-full cursor-pointer touch-none rounded-full", focusRing)}
        style={{ backgroundImage: "linear-gradient(to right, #f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00)" }}
      >
        <span
          className="pointer-events-none absolute top-1/2 size-4 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-[0_0_0_1px_rgba(0,0,0,0.3)]"
          style={{ left: `${(hsv.h / 360) * 100}%`, backgroundColor: `hsl(${hsv.h} 100% 50%)` }}
        />
      </div>

      <div className="mt-3 flex items-center gap-2">
        <span aria-hidden className="size-8 shrink-0 rounded-md border" style={{ backgroundColor: hex }} />
        <input
          aria-label="Hex color"
          spellCheck={false}
          value={draft ?? hex}
          onChange={(event) => {
            const text = event.target.value
            setDraft(text)
            const parsed = hexToHsv(text)
            if (parsed) {
              setHsv(parsed)
              onValueChange?.(hsvToHex(parsed))
            }
          }}
          onBlur={() => setDraft(null)}
          className="h-8 min-w-0 flex-1 rounded-md border border-input bg-background px-2 font-mono text-sm uppercase outline-none focus-visible:ring-4 focus-visible:ring-ring/30"
        />
      </div>

      <div className="mt-3 grid grid-cols-10 gap-1.5" role="group" aria-label="Preset colors">
        {swatches.map((swatch) => (
          <button
            key={swatch}
            type="button"
            aria-label={swatch}
            onClick={() => {
              const parsed = hexToHsv(swatch)
              if (parsed) update(parsed)
            }}
            className={cn(
              "aspect-square rounded-full border outline-none transition-transform hover:scale-110 focus-visible:ring-4 focus-visible:ring-ring/40",
              hex.toLowerCase() === swatch.toLowerCase() && "ring-2 ring-foreground ring-offset-1 ring-offset-popover",
            )}
            style={{ backgroundColor: swatch }}
          />
        ))}
      </div>
    </div>
  )
}
```
