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.

Choose your brand's primary color.

With a default value

The field's default hex value pre-fills the swatch and trigger.

source

conform-color-picker.tsx

Copy 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>
  )
}