Skip to content
Pickers & Media primitive

Color Picker

A swatch grid as a radio group, a hex field that accepts what people actually paste and normalises on blur, and the native picker as the escape hatch.

187 lineslucide-reactAdded 13 Sept 2026
  • color
  • swatch
  • hex
  • picker
  • palette
  • brand

What's included

  • components/ui/color-picker.tsx
  • Needs lucide-react

Works with

  • React
  • Next.js
  • Tailwind CSS
  • TypeScript

npx hoverlab add color-picker

Or over MCP, from your editor's agent — no account needed.

License

Free to read, copy and install, for personal and non-commercial projects. Shipping it in client work or a paid product needs Pro ($79 once). The source lands in your repo and stops being ours — no attribution, nothing to upgrade.

Was this useful?

Live

Interactive, in your current theme. Every state below is the real component — type in it, tab through it, switch the theme.

Source

One file. Create components/ui/color-picker.tsx and paste.

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

/**
 * <ColorPicker> — a swatch grid, a hex field, and the native picker.
 *
 * Deliberately not a saturation-value square with a hue slider. That
 * control is a pointer-capture problem, a colour-space conversion and a
 * keyboard story of its own, and in a product it is almost always the wrong
 * offer: people picking a brand colour paste a hex, and people picking a
 * label colour want the eight the design system allows. So this is the two
 * cases that matter, plus `<input type="color">` as the escape hatch —
 * which is a real, accessible, platform-native picker that costs nothing.
 *
 * The hex field accepts what people actually paste: `#a1b2c3`, `a1b2c3`,
 * `#abc`, and uppercase. It normalises on blur rather than on keystroke,
 * because rewriting the field while somebody is typing the fourth character
 * of six is how you make a field impossible to type into.
 *
 * The swatch grid is a radio group — one value, several options — so it is
 * one tab stop with arrow keys, not eleven tab stops.
 */

import * as React from 'react'
import { Check, Pipette } from 'lucide-react'

export interface ColorPickerProps {
  value: string
  onChange: (hex: string) => void
  /** The palette offered. Keep it to what the design system allows. */
  swatches?: string[]
  /** Hide the free-text hex field to restrict input to the swatches. */
  allowCustom?: boolean
  label: string
  className?: string
}

const DEFAULT_SWATCHES = [
  '#0f172a', '#64748b', '#ef4444', '#f97316', '#eab308', '#22c55e',
  '#14b8a6', '#3b82f6', '#6366f1', '#a855f7', '#ec4899', '#ffffff',
]

/** `#abc` / `abc` / `A1B2C3` → `#a1b2c3`, or null if it is not a colour. */
export function normaliseHex(raw: string): string | null {
  const value = raw.trim().replace(/^#/, '')
  if (/^[0-9a-f]{3}$/i.test(value)) {
    return `#${value[0]}${value[0]}${value[1]}${value[1]}${value[2]}${value[2]}`.toLowerCase()
  }
  if (/^[0-9a-f]{6}$/i.test(value)) return `#${value.toLowerCase()}`
  return null
}

/**
 * Whether to draw the tick in black or white on a given swatch.
 *
 * Relative luminance, not a naive average: the eye is far more sensitive to
 * green than to blue, and averaging puts a white tick on a yellow swatch
 * where it disappears.
 */
function isLight(hex: string): boolean {
  const n = Number.parseInt(hex.slice(1), 16)
  const [r, g, b] = [(n >> 16) & 255, (n >> 8) & 255, n & 255].map((c) => {
    const s = c / 255
    return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4
  })
  return 0.2126 * r + 0.7152 * g + 0.0722 * b > 0.45
}

export function ColorPicker({
  value,
  onChange,
  swatches = DEFAULT_SWATCHES,
  allowCustom = true,
  label,
  className = '',
}: ColorPickerProps) {
  const [draft, setDraft] = React.useState<string | null>(null)
  const refs = React.useRef<(HTMLButtonElement | null)[]>([])
  const index = swatches.findIndex((s) => s.toLowerCase() === value.toLowerCase())

  const move = (delta: number) => {
    const next = (Math.max(0, index) + delta + swatches.length) % swatches.length
    onChange(swatches[next])
    refs.current[next]?.focus()
  }

  return (
    <div className={`flex flex-col gap-3 ${className}`}>
      <div
        role="radiogroup"
        aria-label={label}
        onKeyDown={(e) => {
          // A grid, so all four arrows move: horizontally by one, and
          // vertically by a row. The row length is the grid's column count.
          const columns = 6
          const map: Record<string, number> = {
            ArrowRight: 1,
            ArrowLeft: -1,
            ArrowDown: columns,
            ArrowUp: -columns,
          }
          if (e.key in map) {
            e.preventDefault()
            move(map[e.key])
          }
        }}
        className="grid grid-cols-6 gap-2"
      >
        {swatches.map((swatch, i) => {
          const selected = swatch.toLowerCase() === value.toLowerCase()
          return (
            <button
              key={swatch}
              ref={(el) => {
                refs.current[i] = el
              }}
              type="button"
              role="radio"
              aria-checked={selected}
              // The hex IS the name. "Swatch 4" tells a screen reader user
              // nothing, and the colour name is not knowable from the value.
              aria-label={swatch}
              tabIndex={selected || (index === -1 && i === 0) ? 0 : -1}
              onClick={() => onChange(swatch)}
              style={{ backgroundColor: swatch }}
              className="flex h-8 w-full items-center justify-center rounded-md border border-border/60 transition-transform hover:scale-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background motion-reduce:transform-none"
            >
              {selected ? (
                <Check
                  className={`h-4 w-4 ${isLight(swatch) ? 'text-slate-900' : 'text-white'}`}
                  aria-hidden
                />
              ) : null}
            </button>
          )
        })}
      </div>

      {allowCustom ? (
        <div className="flex items-center gap-2">
          <div className="flex h-9 flex-1 items-center rounded-lg border border-border bg-background ps-2.5 focus-within:ring-2 focus-within:ring-ring">
            <span
              aria-hidden
              style={{ backgroundColor: value }}
              className="h-4 w-4 shrink-0 rounded border border-border/60"
            />
            <input
              value={draft ?? value}
              aria-label={`${label} hex value`}
              spellCheck={false}
              onChange={(e) => setDraft(e.target.value)}
              onBlur={() => {
                // Normalised here, not per keystroke: rewriting "#a1b" to
                // "#aa11bb" while somebody is typing "#a1b2c3" makes the
                // field impossible to use.
                const hex = normaliseHex(draft ?? '')
                if (hex) onChange(hex)
                setDraft(null)
              }}
              onKeyDown={(e) => {
                if (e.key === 'Enter') e.currentTarget.blur()
                if (e.key === 'Escape') setDraft(null)
              }}
              className="w-full bg-transparent px-2 font-mono text-sm uppercase text-foreground outline-none border border-transparent"
            />
          </div>

          <label className="relative inline-flex h-9 w-9 shrink-0 cursor-pointer items-center justify-center rounded-lg border border-border bg-background text-muted-foreground transition-colors hover:bg-muted/60 focus-within:ring-2 focus-within:ring-ring">
            <Pipette className="h-4 w-4" aria-hidden />
            <span className="sr-only">Open the system colour picker</span>
            {/*
              The native picker, made invisible rather than hidden: a
              `display:none` input cannot be opened by clicking its label in
              Safari, and `visibility:hidden` has the same problem.
            */}
            <input
              type="color"
              value={value}
              onChange={(e) => onChange(e.target.value)}
              className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
            />
          </label>
        </div>
      ) : null}
    </div>
  )
}

Props

Read out of the component’s own type and signature, so this cannot drift from the source below. Every prop has a default — the component renders standalone before you pass it anything.

PropTypeDefault
valuerequiredstring—
onChangerequired(hex: string) => void—
labelrequiredstring—
swatchesThe palette offered. Keep it to what the design system allows.string[]DEFAULT_SWATCHES
allowCustomHide the free-text hex field to restrict input to the swatches.booleantrue
classNamestring''

For AI

The component, its props, the design tokens it expects and the command that installs it — as one prompt. Paste it into Claude, Cursor, v0 or ChatGPT and what they build around it will match the rest of the catalog instead of inventing its own system.

See the prompt

More pickers & media

View all
Open the full page for this primitive

Gradient Picker

Draggable stops with pointer capture, an angle, and the CSS it produces shown and copyable — with the stop list kept sorted, because an unsorted linear-gradient is not an error, it just renders wrong.

Pickers & Media238 lines1 dep
Open the full page for this primitive

Image Picker

A drop zone that counts drag events instead of flickering on every child boundary, validates on drop where the accept attribute does nothing, and revokes its object URLs.

Pickers & Media184 lines1 dep
Open the full page for this primitive

Credit Card Input

Brand detection, per-brand digit grouping and CVC length, a Luhn check that catches a transposed digit before the network declines it, and the autocomplete tokens that make a card actually autofill.

Pickers & Media279 linesNo deps