component — color

Color Field

Accessible text input for editing a color as a hex value, built on react-aria-components and styled with the quebi design system. Parses and validates color input and threads value, validation, and disabled state to its field primitives.

  • form
  • input
  • color
  • hex
  • interactive

06 examples

Default

A standalone hex color input, with a live swatch of the value.

With label and description

Composing children means placing the control yourself.

Enter a hex value like #22D3EE.

Invalid

Validation surfaces through FieldError.

Please enter a valid hex color.

Disabled

Without the swatch

A bare ColorInput as the child opts out of the chip — no prop needed, because composing the control is already the opt-out.

Controlled

The field owns the chip; a standalone ColorSwatch reads the same value.

#10B981

source

color-field.tsx

Copy this into your project. Resolve its dependencies from the registryDependencies in the component's API entry.

"use client"

import { use } from "react"
import type { ColorFieldProps, ColorSwatchProps, InputProps } from "react-aria-components"
import {
  ColorField as ColorFieldPrimitive,
  ColorFieldStateContext,
  composeRenderProps,
  Group,
  Input as InputPrimitive,
  parseColor,
} from "react-aria-components"
import { ColorSwatch } from "@/components/color-swatch"
import { cn } from "@/lib/utils"
import { fieldStyles } from "@/components/field"

/**
 * ColorField — quebi design system
 *
 * Built on react-aria-components. An accessible text input for editing a color
 * as a hex value (e.g. `#0EA5E9`), with a live swatch of that color inside the
 * field so the value is visible and not only readable.
 *
 * Pass children to compose your own control; otherwise a {@link ColorFieldGroup}
 * — the hex {@link ColorInput} with its leading {@link ColorFieldSwatch} — is
 * rendered automatically so the field works standalone. Composing children is
 * also how you opt out of the swatch: a bare `<ColorInput />` as the child is
 * the old text-box-only field. The Conform color-field variant composes the
 * group explicitly, alongside a label and an error message.
 *
 * Children mean *all* of the inside, including the control — the same contract
 * `TextField`, `SearchField`, `DateField` and `Select` publish, none of which
 * default a control at all. So composing a `<Label>` and forgetting the input
 * gives you a label with nothing under it (task #148, and it shipped once:
 * #125). That stays a composition error rather than something the field
 * repairs, because repairing it means appending an input the field cannot
 * place — the label → control → hint stack is an ordering, and a control
 * inserted after a `FieldError` is a different bug — and cannot even be
 * detected when children are a render function, which react-aria allows.
 *
 * A focused field does *not* step on the wheel: `isWheelDisabled` defaults to
 * `true` here, inverting react-aria's default. See {@link ColorFieldProps}.
 */
export function ColorField({
  className,
  children,
  // react-aria turns a wheel tick over a *focused* field into a value step,
  // and `preventDefault`s the scroll, so the page does not move either: an
  // ordinary read-the-rest-of-the-form scroll silently rewrites the colour
  // with no press, no keystroke and no undo (task #156). Wheel stepping is a
  // real desktop affordance, so this is a default and not a removal — pass
  // `isWheelDisabled={false}` to ask for it back.
  isWheelDisabled = true,
  ...props
}: ColorFieldProps) {
  return (
    <ColorFieldPrimitive
      {...props}
      isWheelDisabled={isWheelDisabled}
      // Only name the field when nothing else does. Unconditionally, this
      // fallback *doubles* the name of every labelled field: react-aria folds
      // an `aria-label` into the input's own `aria-labelledby` chain alongside
      // the `<Label>`, so a field labelled "Brand color" announced itself as
      // "Color field Brand color" (task #147). Children are the proxy for "a
      // Label is in there", because composing them is the only way to put one
      // there; `aria-labelledby` is the other way to name the standalone
      // field. Same shape as the month / week / year pickers.
      aria-label={
        props["aria-label"] ?? (children || props["aria-labelledby"] ? undefined : "Color field")
      }
      data-slot="control"
      className={composeRenderProps(className, (resolved) =>
        // The stack from the one place that owns it.
        cn(fieldStyles, resolved),
      )}
    >
      {children ?? <ColorFieldGroup />}
    </ColorFieldPrimitive>
  )
}

export interface ColorFieldGroupProps {
  /** The control inside the field. Defaults to the hex {@link ColorInput}. */
  children?: React.ReactNode
  className?: string
}

/**
 * ColorFieldGroup — a {@link ColorField}'s input with its color chip.
 *
 * The swatch sits at the start of the input, inside the border, on the same
 * adornment geometry `InputGroup` uses for a leading icon: absolutely
 * positioned, out of the pointer path, with the input padded to make room. One
 * rectangle, so the field's label/description/error stack is unchanged.
 */
export function ColorFieldGroup({ children, className }: ColorFieldGroupProps) {
  return (
    <Group
      data-slot="control"
      className={cn(
        "relative isolate block w-full",
        // Room for the chip the swatch draws over the start of the field,
        // past the field's own inline padding.
        "[&_input]:ps-[calc(var(--q-field-px)+--spacing(8))]",
        className,
      )}
    >
      <ColorFieldSwatch />
      {children ?? <ColorInput />}
    </Group>
  )
}

/**
 * ColorFieldSwatch — the live color chip inside a {@link ColorField}.
 *
 * Reads the field's own state off `ColorFieldStateContext`, so it takes no
 * color prop and needs no state lifted out of the field. What it shows is the
 * color the field would hold if it were committed right now — which is what
 * react-aria does on blur, so the chip is never showing something the input is
 * about to contradict:
 *
 * - the input parses → that color, live, as the user types;
 * - it does not parse → the last committed color, which is what blur restores;
 * - it is empty → nothing, which is what blur commits.
 *
 * "Nothing" is a fully transparent swatch rather than no element, so the
 * field's geometry never moves; react-aria's own swatch renders `#fff0` for a
 * missing color and labels it "transparent".
 */
export function ColorFieldSwatch({ className, ...props }: ColorSwatchProps) {
  const state = use(ColorFieldStateContext)

  return (
    <ColorSwatch
      color={liveColor(state) ?? undefined}
      className={composeRenderProps(className, (resolved) =>
        cn(
          "pointer-events-none absolute start-(--q-field-px) top-1/2 z-10 size-5 -translate-y-1/2",
          "in-data-[disabled]:opacity-50",
          resolved,
        ),
      )}
      {...props}
    />
  )
}

/** The color a {@link ColorField}'s state would commit to right now. */
function liveColor(state: React.ContextType<typeof ColorFieldStateContext>) {
  if (!state) return null
  // A `channel` ColorField puts a NumberFieldState in the same context. Its
  // inputValue is a channel number ("180"), which would parse as the hex
  // #118800, and its colorValue is already the whole color — so leave it alone.
  if ("numberValue" in state) return state.colorValue

  const typed = state.inputValue.trim()
  if (typed === "") return null
  try {
    return parseColor(typed.startsWith("#") ? typed : `#${typed}`)
  } catch {
    return state.colorValue
  }
}

/** The hex text input inside a {@link ColorField}. */
export function ColorInput({ className, ...props }: InputProps) {
  return (
    <InputPrimitive
      {...props}
      data-slot="control"
      className={composeRenderProps(className, (resolved) =>
        cn(
          "relative block w-full appearance-none text-sm text-quebi-fg tabular-nums uppercase",
          "placeholder:text-quebi-fg-subtle placeholder:normal-case",
          // `Input`'s frame at `md`; focus doubles it, no ring.
          "quebi-field px-(--q-field-px) py-2.5",
          "transition-[border-color,box-shadow] duration-150",
          "outline-none focus:outline-none focus:shadow-(--q-field-focus)",
          "invalid:[--q-field-edge:var(--q-danger)] data-invalid:[--q-field-edge:var(--q-danger)]",
          "focus:invalid:shadow-(--q-field-focus-danger) focus:data-invalid:shadow-(--q-field-focus-danger)",
          "disabled:cursor-not-allowed disabled:opacity-50 in-disabled:opacity-50",
          resolved,
        ),
      )}
    />
  )
}

export type { ColorFieldProps }