Skip to contentVibraUI
Inputs & filters

Color picker

A swatch grid with a hex field and the system picker behind it, for choosing one colour.

Controlled only. A swatch is matched through normalizeHex on both sides, so #F00 checks the #FF0000 swatch. The hex field holds a half-typed value, so it keeps its own text — every value arriving from the caller replaces it, and an entry the caller will not take snaps back on blur rather than sitting there as a lie. The check on the chosen swatch is blended against the swatch, so it stays readable on any hue without a second colour per swatch. The system picker is the label's own control, so the "Custom" square opens it on a click and Tab still lands on a real input. isValidHex and normalizeHex are exported for reading a hex out of pasted text elsewhere.

Install

npx shadcn@latest add @vibra/color-picker

Needs the @vibra registry in your components.json — set it up once.

Examples

Props

PropTypeDefaultDescription
valuestring—The chosen colour as a hex string, e.g. #3B82F6.
onValueChange(hex: string) => void—Called with a normalised uppercase six-digit hex.
presetsstring[]twelve huesThe swatch grid, six to a row.
allowCustombooleantrueOffers the hex field and the system picker under the swatches.
size"sm" | "default""default"sm drops the trigger to h-7 for dense rows.
disabledbooleanfalseDims the trigger and stops it opening.
aria-labelstring"Color"Names the trigger; the current hex is appended to it.
isValidHex(value: string) => boolean—Whether a string is #RGB or #RRGGBB, in either case.
normalizeHex(value: string) => string—Strips spaces, adds the hash, expands the shorthand, uppercases: "abc" becomes "#AABBCC".
classNamestring—Merged onto the trigger, which is the root; the remaining button props are spread onto it too.

Dependencies

Source

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

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

import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"

const HEX = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i

/** Whether a string is a hex colour — #RGB or #RRGGBB, in either case. */
export function isValidHex(value: string): boolean {
  return HEX.test(value)
}

/** Puts a hex into one shape — "abc" and " #AaBbCc " both become "#AABBCC"; anything unreadable comes back for the caller to reject. */
export function normalizeHex(value: string): string {
  const body = value.replace(/\s+/g, "").replace(/^#/, "")
  if (!/^([0-9a-f]{3}|[0-9a-f]{6})$/i.test(body)) return `#${body.toUpperCase()}`
  const full =
    body.length === 3
      ? body
          .split("")
          .map((digit) => digit + digit)
          .join("")
      : body
  return `#${full.toUpperCase()}`
}

/** The default swatches: eight hues plus a neutral, at one lightness. */
const DEFAULT_PRESETS = [
  "#EF4444",
  "#F97316",
  "#F59E0B",
  "#84CC16",
  "#22C55E",
  "#14B8A6",
  "#06B6D4",
  "#3B82F6",
  "#6366F1",
  "#8B5CF6",
  "#EC4899",
  "#64748B",
]

// The trigger button is the root: className and the rest of the props land on
// it. The five names below are re-declared because the item owns them —
// className is narrowed back to a string, and size is the item's density axis
// rather than the button's.
export type ColorPickerProps = Omit<
  React.ComponentProps<typeof Button>,
  "value" | "className" | "size" | "variant" | "disabled" | "aria-label" | "children" | "render"
> & {
  value: string
  onValueChange: (hex: string) => void
  presets?: string[]
  /** Offers the hex field and the system colour picker below the swatches. */
  allowCustom?: boolean
  size?: "sm" | "default"
  className?: string
  disabled?: boolean
  /** Names the trigger, e.g. "Series colour"; the current hex is appended to it. */
  "aria-label"?: string
}

/** A swatch grid with a hex field and the system picker behind it, for choosing one colour. */
function ColorPicker({
  value,
  onValueChange,
  presets = DEFAULT_PRESETS,
  allowCustom = true,
  size = "default",
  className,
  disabled = false,
  "aria-label": ariaLabel = "Color",
  ...props
}: ColorPickerProps) {
  // The field holds a half-typed hex, so it keeps its own text; every value
  // that arrives from the caller replaces it, and an entry the caller will not
  // take snaps back on blur rather than sitting there as a lie.
  const [text, setText] = React.useState(value)
  const [lastValue, setLastValue] = React.useState(value)
  if (value !== lastValue) {
    setLastValue(value)
    setText(value)
  }

  function pick(hex: string) {
    setText(hex)
    if (hex !== value) onValueChange(hex)
  }

  function commitTypedHex() {
    const next = normalizeHex(text)
    if (isValidHex(next)) pick(next)
    else setText(value)
  }

  return (
    <Popover>
      <PopoverTrigger
        render={
          <Button
            type="button"
            data-slot="color-picker"
            data-size={size}
            variant="outline"
            size={size === "sm" ? "sm" : "default"}
            disabled={disabled}
            aria-label={`${ariaLabel}: ${value}`}
            className={cn("justify-start gap-2 font-normal", className)}
            {...props}
          >
            <Swatch color={value} />
            <span className="font-mono text-xs">{value}</span>
          </Button>
        }
      />

      {/* COUPLED TO registry/vibra/ui/popover.tsx: six swatches to a row set the
          width here, so the panel's own w-72 would leave a dead column. */}
      <PopoverContent align="start" className="w-56">
        <div
          data-slot="color-picker-swatches"
          className="grid grid-cols-6 gap-1.5"
        >
          {presets.map((preset) => {
            // Both sides through normalizeHex, so #F00 checks the #FF0000 swatch.
            const selected = normalizeHex(preset) === normalizeHex(value)
            return (
              <button
                key={preset}
                type="button"
                data-slot="color-picker-swatch"
                aria-label={preset}
                aria-pressed={selected}
                onClick={() => pick(preset)}
                className="flex size-7 items-center justify-center rounded-md ring-1 ring-foreground/15 ring-inset transition-transform duration-(--duration-fast) ease-(--ease-standard) hover:scale-105 focus-ring"
                style={{ backgroundColor: preset }}
              >
                {/* Difference blending inverts the swatch underneath, so the
                    tick stays readable on any hue without a second token per
                    swatch. It needs a literal white to invert against — there is
                    no token whose value is guaranteed to be pure white in both
                    themes, and this one is a blend operand, not a palette
                    choice. */}
                <CheckIcon
                  className={cn(
                    "size-3.5 text-white mix-blend-difference",
                    selected ? "opacity-100" : "opacity-0"
                  )}
                />
              </button>
            )
          })}
        </div>

        {allowCustom ? (
          <div data-slot="color-picker-custom" className="flex items-center gap-1.5">
            <InputGroup className="flex-1">
              <InputGroupAddon>
                <Swatch color={isValidHex(normalizeHex(text)) ? normalizeHex(text) : value} />
              </InputGroupAddon>
              <InputGroupInput
                aria-label="Hex color"
                autoComplete="off"
                spellCheck={false}
                value={text}
                onChange={(event) => setText(event.target.value)}
                onBlur={commitTypedHex}
                onKeyDown={(event) => {
                  if (event.key !== "Enter") return
                  event.preventDefault()
                  commitTypedHex()
                }}
                className="font-mono text-xs uppercase"
              />
            </InputGroup>

            {/* The native picker is the label's control, so the swatch opens it
                on a click and Tab still lands on a real input. */}
            <label
              data-slot="color-picker-native"
              className="flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-lg border border-input transition-colors has-[input:focus-visible]:focus-outline"
              style={{ backgroundColor: value }}
            >
              <span className="sr-only">Custom color</span>
              <input
                type="color"
                className="sr-only"
                value={isValidHex(value) ? normalizeHex(value) : "#000000"}
                onChange={(event) => pick(normalizeHex(event.target.value))}
              />
            </label>
          </div>
        ) : null}
      </PopoverContent>
    </Popover>
  )
}

function Swatch({ color }: { color: string }) {
  return (
    <span
      aria-hidden="true"
      className="size-4 shrink-0 rounded-[4px] ring-1 ring-foreground/15 ring-inset"
      style={{ backgroundColor: color }}
    />
  )
}

export { ColorPicker }