component — conform

Conform Color Swatch Picker

A multi-select grid of color swatches wired to Conform. A real multi-select listbox, so every swatch reports its own aria-selected and the grid is navigable by keyboard; the selected keys are submitted as one comma-joined value through a registered control, so they repopulate after a failed submit and reset with the form, and an optional react-stately list mirrors them as removable tags.

  • form
  • conform
  • color
  • swatch
  • picker
  • multi-select
  • validation

03 examples

Bound to a Conform form

The form declares teal as the default. Toggle swatches to build the selection; Reset snaps it back, because the value lives in the field rather than beside it.

Select one or more colors. The selection is submitted as a list of keys.

Validation

Submit with nothing selected to see the error wired from field metadata.

Mirrored into a tag list

Pass an optional react-stately list to surface the selection elsewhere. Removing a tag writes the field; Reset re-seeds the list from the form's default.

source

conform-color-swatch-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 } from "@conform-to/react/future"
import { useRef } from "react"
import type { Selection } from "react-aria-components"
import { ListBox, ListBoxItem } from "react-aria-components"
import type { ListData } from "react-stately"
import { type ConformListItem, useConformListControl } from "@/lib/conform-list-control"
import { cn } from "@/lib/utils"
import { ColorSwatch } from "@/components/color-swatch"
import {
  describedBy,
  Description,
  Field,
  FieldError,
  focusFirstControl,
  Label,
} from "@/components/field"

/** A selectable color: a stable `key` submitted to the form plus its `hex` swatch. */
export interface SwatchColor {
  key: string
  hex: string
  label?: string
}

/** Default palette used when no `colors` prop is supplied. */
export const DEFAULT_SWATCH_COLORS: SwatchColor[] = [
  { key: "black", hex: "#1a1a1a" },
  { key: "white", hex: "#f5f5f5" },
  { key: "gray", hex: "#9ca3af" },
  { key: "red", hex: "#ef4444" },
  { key: "orange", hex: "#f97316" },
  { key: "yellow", hex: "#eab308" },
  { key: "green", hex: "#22c55e" },
  { key: "teal", hex: "#14b8a6" },
  { key: "blue", hex: "#3b82f6" },
  { key: "purple", hex: "#a855f7" },
]

export interface ConformColorSwatchPickerProps {
  /**
   * A field whose value is a set of color keys. List-backed fields surface as
   * `string | string[]` (a comma-joined string on the wire, an array after
   * parsing); only name/errors/validity are read off the metadata.
   */
  field: FieldMetadata<string | string[]>
  label?: string
  description?: string
  /**
   * Optional react-stately list mirroring the selection, so the same colors can
   * be rendered and removed as tags elsewhere on the page. The picker owns the
   * value either way: removing an item pushes into the field, and a reset or a
   * `form.update()` re-seeds the list.
   */
  list?: ListData<ConformListItem>
  /** Selectable colors. Defaults to {@link DEFAULT_SWATCH_COLORS}. */
  colors?: SwatchColor[]
  className?: string
}

/**
 * ConformColorSwatchPicker — multi-select color picker wired to Conform.
 *
 * A wrapping grid of named color swatches whose selected keys are submitted as
 * one comma-joined string through a registered hidden control, so they
 * repopulate after a failed submit and reset with the form like every other
 * variant.
 *
 * The grid is a multi-select `ListBox` rather than the quebi ColorSwatchPicker,
 * and that is the whole point of the component. react-aria's ColorSwatchPicker
 * hardcodes `selectionMode: "single"` and derives its selection from a single
 * color *value*, so a multi-select grid cannot be built on it: every swatch
 * renders `role="option" aria-selected="false"`, including the selected ones,
 * and a ring drawn by hand is the only thing that says otherwise. Here the
 * selection is real — `aria-selected`, `data-[selected]`, `aria-multiselectable`
 * and keyboard multi-select (arrow keys across the grid, Space/Enter to toggle)
 * all come from the listbox. The single-select quebi ColorSwatchPicker stays the
 * right component for picking one color.
 *
 * The listbox is the *view* of that selection, not its home: `selectedKeys`
 * comes off the registered control, and `onSelectionChange` writes back to it.
 *
 * `list` is optional and is a projection of that value rather than its home —
 * see `@/lib/conform-list-control` for why. The default selection is the
 * field's, not the list's: seed `useForm({ defaultValue: { colors: ["teal"] } })`
 * and the list follows, which is also what a reset goes back to.
 */
export function ConformColorSwatchPicker({
  field,
  label,
  description,
  list,
  colors = DEFAULT_SWATCH_COLORS,
  className,
}: ConformColorSwatchPickerProps) {
  const fieldRef = useRef<HTMLDivElement>(null)
  const selection = useConformListControl({
    initialValue: field.initialValue,
    list,
    // 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

  /**
   * Apply the listbox's selection to the control (and through it, to any list).
   *
   * `"all"` reaches here from Ctrl+A, which react-aria reports as the whole
   * collection rather than as a set of keys.
   */
  const applySelection = (next: Selection) => {
    selection.select(next === "all" ? colors.map((color) => color.key) : [...next].map(String))
  }

  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={selection.register}
        defaultValue={selection.defaultValue}
        hidden={false}
        tabIndex={-1}
        className="sr-only"
      />

      {label && (
        <Label className={cn(hasErrors && "text-quebi-danger")}>
          {label}
          {field.required && <span className="ml-1 text-quebi-fg">*</span>}
        </Label>
      )}
      {/* These ids are ours to set: the swatch grid is not a react-aria field,
          so nothing generates them and the aria-describedby below is their only
          reference. */}
      {description && <Description id={field.descriptionId}>{description}</Description>}

      {/* `data-invalid` rather than `aria-invalid`: react-aria's ListBox filters
          its incoming DOM props down to `id`, the labelable set and `data-*`, so
          an aria-invalid here would be dropped without a word. The error text is
          still announced — aria-describedby is in the labelable set and survives. */}
      <ListBox
        data-slot="control"
        aria-label={label ?? "Colors"}
        data-invalid={hasErrors || undefined}
        aria-describedby={describedBy(
          hasErrors && field.errorId,
          description && field.descriptionId,
        )}
        layout="grid"
        selectionMode="multiple"
        selectionBehavior="toggle"
        selectedKeys={selection.keys}
        onSelectionChange={applySelection}
        className="flex flex-wrap gap-2 outline-hidden"
      >
        {colors.map((color) => (
          <ListBoxItem
            key={color.key}
            id={color.key}
            textValue={color.label ?? color.key}
            aria-label={color.label ?? color.key}
            // The quebi ColorSwatchPickerItem's look, on a listbox option: the
            // ring is keyed off `data-[selected]`, which react-aria now sets,
            // rather than off a className the component computes for itself.
            // Selection is an `fg` halo, focus the `focus` ring — both ink, so
            // the only hue on the row is the swatches' own.
            className={cn(
              "relative rounded-none outline-hidden",
              "*:rounded-none",
              "transition-opacity duration-150",
              "data-[selected]:ring-2 data-[selected]:ring-quebi-fg data-[selected]:ring-offset-2 data-[selected]:ring-offset-quebi-bg",
              "data-[focus-visible]:ring-2 data-[focus-visible]:ring-quebi-focus data-[focus-visible]:ring-offset-2 data-[focus-visible]:ring-offset-quebi-bg",
              "hover:opacity-90",
            )}
          >
            <ColorSwatch color={color.hex} className="size-8" />
          </ListBoxItem>
        ))}
      </ListBox>

      {hasErrors && <FieldError id={field.errorId}>{field.errors?.join(", ")}</FieldError>}
    </Field>
  )
}