component — conform
Conform Color Picker
The quebi Color Picker wired to Conform. Binds a hex-string field's name, default, required, and validity from field metadata, mirrors the chosen color into a hidden input, and renders inline errors.
- form
- conform
- color
- color-picker
- validation
02 examples
Bound to a Conform form
Submit without picking a color to see the validation error wired from field metadata.
With a default value
The field's default hex value pre-fills the swatch and trigger.
source
conform-color-picker.tsxCopy this into your project. Resolve its dependencies from the registryDependencies in the component's API entry.
"use client"
import type { FieldMetadata } from "@conform-to/react"
import { BaseControl, useControl } from "@conform-to/react/future"
import { useRef } from "react"
import type { Color } from "react-aria-components"
import { ColorPicker as ColorPickerPrimitive, parseColor } from "react-aria-components"
import { useFieldSizing } from "@/lib/field-size"
import { cn } from "@/lib/utils"
import { ColorArea } from "@/components/color-area"
import { ColorField } from "@/components/color-field"
import { EyeDropper } from "@/components/color-picker"
import { ColorSlider, ColorSliderThumb, ColorSliderTrack } from "@/components/color-slider"
import { ColorSwatch } from "@/components/color-swatch"
import { Dialog } from "@/components/dialog"
import {
describedBy,
Description,
Field,
FieldError,
focusFirstControl,
Label,
} from "@/components/field"
import { Popover, PopoverBody, PopoverContent, PopoverTrigger } from "@/components/popover"
export interface ConformColorPickerProps {
/** A colour bound to a string form value — an uppercase `#RRGGBB` hex. */
field: FieldMetadata<string>
label?: string
placeholder?: string
description?: string
className?: string
onValueChange?: (value: string) => void
}
/** Parse a hex string into a Color, or null when it is empty or malformed. */
function tryParseColor(hex: string): Color | null {
try {
return parseColor(hex)
} catch {
return null
}
}
/**
* ConformColorPicker — full color picker wired to Conform.
*
* Binds a hex-string Conform field to the quebi color primitives: a popover
* with a saturation/brightness area, a hue slider, a hex field, and an
* eyedropper, submitted through a registered hidden input.
*
* The hex string lives in Conform's state rather than in `useState`, so it
* survives a failed submit and snaps back on a form reset. The registered input
* is hidden by CSS rather than by the `hidden` attribute, and carries no React
* `value` prop; both matter and both fail silently — see `conform-time-field`.
*/
export function ConformColorPicker({
field,
label,
placeholder,
description,
className,
onValueChange,
}: ConformColorPickerProps) {
const fieldRef = useRef<HTMLDivElement>(null)
// The trigger is a Button, whose `xs` and `sm` are the field scale's 30px and
// 38px exactly — so a colour picker in a table cell is the height of the row
// it is in, like every other control there. `md` is Button's own default, so
// nothing changes outside a surface that asked for a size.
const { size } = useFieldSizing()
const control = useControl({
defaultValue: (field.initialValue as string) ?? "",
// Conform focuses the first errored field after a failed submit; that is
// the registered control, which nobody can see — hand it to the visible one.
onFocus() {
focusFirstControl(fieldRef.current)
},
})
const hasErrors = !field.valid && !!field.errors
const isRequired = field.required ?? false
const hexValue = control.value ?? ""
const isEmpty = hexValue === ""
const color =
tryParseColor(hexValue) ?? tryParseColor(placeholder ?? "") ?? parseColor("#000000")
const displayColor = isEmpty ? (placeholder ?? "#000000") : hexValue
const handleColorChange = (newColor: Color) => {
const next = newColor.toString("hex").toUpperCase()
control.change(next)
onValueChange?.(next)
}
return (
<Field ref={fieldRef} className={cn(className)}>
{/* First, before the label: the stack is spaced with `label + control`
selectors, and an `sr-only` element between the two is still an
element the selector cannot see past. */}
<BaseControl
name={field.name}
form={field.formId}
ref={control.register}
defaultValue={control.defaultValue ?? ""}
hidden={false}
tabIndex={-1}
className="sr-only"
/>
{label && (
<Label className={cn(hasErrors && "text-quebi-danger")}>
{label}
{isRequired && <span className="ml-1 text-quebi-fg">*</span>}
</Label>
)}
{/* `ColorPicker` is a context provider and renders no element of its
own, so the trigger button would otherwise be what the field stack
and `FieldRow` saw. This wrapper is the control. */}
<div data-slot="control">
<ColorPickerPrimitive value={color} onChange={handleColorChange}>
<Popover>
<PopoverTrigger
intent="outline"
size={size}
className="w-full justify-start gap-2 font-normal"
aria-describedby={describedBy(
hasErrors && field.errorId,
description && field.descriptionId,
)}
>
<ColorSwatch color={displayColor} className="size-5 shrink-0" />
<span className={isEmpty ? "text-quebi-fg-subtle" : undefined}>
{isEmpty ? (placeholder ?? "Select color") : hexValue}
</span>
</PopoverTrigger>
<PopoverContent className="w-[280px]">
<Dialog>
<PopoverBody className="space-y-3 p-3">
<ColorArea colorSpace="hsb" xChannel="saturation" yChannel="brightness" className="w-full" />
<ColorSlider channel="hue" colorSpace="hsb">
<ColorSliderTrack>
<ColorSliderThumb />
</ColorSliderTrack>
</ColorSlider>
{/* One height across the row. `ColorInput` is the field
scale's `md`, 42px, and Button's square scale steps 38 → 46
with nothing in between — so the dropper and the swatch are
sized to the field rather than the field to them. */}
<div className="flex items-center gap-2">
<ColorField aria-label="Hex color" className="flex-1" />
<EyeDropper className="size-10.5" />
<ColorSwatch className="size-10.5 shrink-0" />
</div>
</PopoverBody>
</Dialog>
</PopoverContent>
</Popover>
</ColorPickerPrimitive>
</div>
{/* These ids are ours to set: the trigger is a Button inside a popover,
not a react-aria field, so nothing generates them and the
aria-describedby above is their only reference. */}
{description && <Description id={field.descriptionId}>{description}</Description>}
{hasErrors && <FieldError id={field.errorId}>{field.errors?.join(", ")}</FieldError>}
</Field>
)
}