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-pickerNeeds the @vibra registry in your components.json — set it up once.
Examples
"use client"
import * as React from "react"
import { ColorPicker } from "@/components/ui/color-picker"
export default function ColorPickerDemo() {
const [revenue, setRevenue] = React.useState("#3B82F6")
const [churn, setChurn] = React.useState("#EF4444")
return (
<div className="flex w-full max-w-sm flex-col gap-3">
<div className="flex items-center justify-between gap-3">
<span className="text-sm">Revenue series</span>
<ColorPicker aria-label="Revenue series color" value={revenue} onValueChange={setRevenue} />
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-sm">Churn series</span>
<ColorPicker
aria-label="Churn series color"
value={churn}
onValueChange={setChurn}
size="sm"
/>
</div>
<div className="flex h-10 items-end gap-1 rounded-lg border border-input p-2">
{[38, 52, 44, 61, 70].map((height, index) => (
<span
key={height}
className="flex-1 rounded-[3px]"
style={{ height: `${height}%`, backgroundColor: index === 4 ? churn : revenue }}
/>
))}
</div>
</div>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| value | string | — | The chosen colour as a hex string, e.g. #3B82F6. |
| onValueChange | (hex: string) => void | — | Called with a normalised uppercase six-digit hex. |
| presets | string[] | twelve hues | The swatch grid, six to a row. |
| allowCustom | boolean | true | Offers the hex field and the system picker under the swatches. |
| size | "sm" | "default" | "default" | sm drops the trigger to h-7 for dense rows. |
| disabled | boolean | false | Dims the trigger and stops it opening. |
| aria-label | string | "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". |
| className | string | — | Merged onto the trigger, which is the root; the remaining button props are spread onto it too. |
Dependencies
Source
"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 }