Skip to content

ColorField

A numeric input component for editing a single color channel, with optional stepper buttons and a color swatch preview.

Preview

Source code
tsx
import { ColorField, useColor } from "@urcolor/react";

export default function ColorFieldHSL() {
  const { color, setColor, channels } = useColor("hsl(210, 80%, 50%)", "hsl");

  return (
    <div className="flex items-start gap-4">
      <div className="flex flex-1 flex-wrap gap-2">
        {channels.map((ch) => (
          <div key={ch.key} className="flex min-w-[80px] flex-1 flex-col gap-1">
            <label
              htmlFor={`field-${ch.key}`}
              className="text-xs font-semibold text-[var(--vp-c-text-2)]"
            >
              {ch.label}
            </label>
            <ColorField.Root
              value={color}
              onValueChange={setColor}
              colorSpace="hsl"
              channel={ch.key}
              className="
                flex items-center overflow-hidden rounded-md border
                border-[var(--vp-c-divider)] bg-[var(--vp-c-bg)]
              "
            >
              <ColorField.Decrement
                className="
                  flex size-8 shrink-0 cursor-pointer items-center justify-center
                  border-r border-none border-r-[var(--vp-c-divider)] bg-transparent
                  text-lg leading-none text-[var(--vp-c-text-2)] select-none
                  hover:not-disabled:bg-[var(--vp-c-bg-soft)]
                  hover:not-disabled:text-[var(--vp-c-text-1)]
                  disabled:cursor-default disabled:opacity-30
                "
              >
                &minus;
              </ColorField.Decrement>
              <ColorField.Input
                id={`field-${ch.key}`}
                className="
                  w-0 min-w-0 flex-1 border-none bg-transparent px-0.5 py-1
                  text-center font-mono text-[13px] text-[var(--vp-c-text-1)]
                  outline-none
                "
              />
              <ColorField.Increment
                className="
                  flex size-8 shrink-0 cursor-pointer items-center justify-center
                  border-l border-none border-l-[var(--vp-c-divider)] bg-transparent
                  text-lg leading-none text-[var(--vp-c-text-2)] select-none
                  hover:not-disabled:bg-[var(--vp-c-bg-soft)]
                  hover:not-disabled:text-[var(--vp-c-text-1)]
                  disabled:cursor-default disabled:opacity-30
                "
              >
                +
              </ColorField.Increment>
            </ColorField.Root>
          </div>
        ))}
      </div>
    </div>
  );
}

Anatomy

tsx
<ColorField.Root>
  <ColorField.Swatch />
  <ColorField.Decrement />
  <ColorField.Input />
  <ColorField.Increment />
</ColorField.Root>

ColorField.Swatch takes its own value and reads nothing from the root's context, so it can sit anywhere in the tree, inside the root, or beside it.

Examples

Hex Input

format="hex" switches the field from editing one channel to editing the whole color as a #rrggbb string. In that mode channel is not read at all.

Source code
tsx
import { ColorField, useColor } from "@urcolor/react";

export default function ColorFieldHex() {
  const { color, setColor } = useColor("hsl(210, 80%, 50%)");

  return (
    <div className="flex items-center gap-3">
      <ColorField.Root
        value={color}
        onValueChange={setColor}
        channel="hex"
        format="hex"
        className="
          flex h-8 items-center overflow-hidden rounded-md border
          border-[var(--vp-c-divider)] bg-[var(--vp-c-bg)] px-3
        "
      >
        <ColorField.Input
          className="
            min-w-0 flex-1 border-none bg-transparent px-3 py-1.5 font-mono
            text-[13px] text-[var(--vp-c-text-1)] outline-none
          "
        />
      </ColorField.Root>
    </div>
  );
}

HSL Channel Fields

HSL channel inputs with stepper buttons. One root per channel, all fed the same color.

Source code
tsx
import { ColorField, useColor } from "@urcolor/react";

export default function ColorFieldHSL() {
  const { color, setColor, channels } = useColor("hsl(210, 80%, 50%)", "hsl");

  return (
    <div className="flex items-start gap-4">
      <div className="flex flex-1 flex-wrap gap-2">
        {channels.map((ch) => (
          <div key={ch.key} className="flex min-w-[80px] flex-1 flex-col gap-1">
            <label
              htmlFor={`field-${ch.key}`}
              className="text-xs font-semibold text-[var(--vp-c-text-2)]"
            >
              {ch.label}
            </label>
            <ColorField.Root
              value={color}
              onValueChange={setColor}
              colorSpace="hsl"
              channel={ch.key}
              className="
                flex items-center overflow-hidden rounded-md border
                border-[var(--vp-c-divider)] bg-[var(--vp-c-bg)]
              "
            >
              <ColorField.Decrement
                className="
                  flex size-8 shrink-0 cursor-pointer items-center justify-center
                  border-r border-none border-r-[var(--vp-c-divider)] bg-transparent
                  text-lg leading-none text-[var(--vp-c-text-2)] select-none
                  hover:not-disabled:bg-[var(--vp-c-bg-soft)]
                  hover:not-disabled:text-[var(--vp-c-text-1)]
                  disabled:cursor-default disabled:opacity-30
                "
              >
                &minus;
              </ColorField.Decrement>
              <ColorField.Input
                id={`field-${ch.key}`}
                className="
                  w-0 min-w-0 flex-1 border-none bg-transparent px-0.5 py-1
                  text-center font-mono text-[13px] text-[var(--vp-c-text-1)]
                  outline-none
                "
              />
              <ColorField.Increment
                className="
                  flex size-8 shrink-0 cursor-pointer items-center justify-center
                  border-l border-none border-l-[var(--vp-c-divider)] bg-transparent
                  text-lg leading-none text-[var(--vp-c-text-2)] select-none
                  hover:not-disabled:bg-[var(--vp-c-bg-soft)]
                  hover:not-disabled:text-[var(--vp-c-text-1)]
                  disabled:cursor-default disabled:opacity-30
                "
              >
                +
              </ColorField.Increment>
            </ColorField.Root>
          </div>
        ))}
      </div>
    </div>
  );
}

Alpha Channel

channel="alpha" edits opacity. It is not a channel of any color space: the root special-cases it and presents it as a 0–100 percentage.

tsx
<ColorField.Root value={color} onValueChange={setColor} channel="alpha">
  <ColorField.Swatch value={color} alpha />
  <ColorField.Decrement>&minus;</ColorField.Decrement>
  <ColorField.Input aria-label="Alpha" />
  <ColorField.Increment>+</ColorField.Increment>
</ColorField.Root>

Bounds and Step

min, max and step all default to the resolved channel's own configuration. Setting them narrows the range the field will accept and changes how far one arrow press moves.

tsx
<ColorField.Root
  value={color}
  onValueChange={setColor}
  colorSpace="hsl"
  channel="l"
  min={20}
  max={80}
  step={5}
>
  <ColorField.Input aria-label="Lightness" />
</ColorField.Root>

API Reference

Every part is also exported unnamespaced, ColorFieldRoot, ColorFieldInput, ColorFieldIncrement, ColorFieldDecrement, ColorFieldSwatch, alongside the ColorField.* namespace. The root's context is readable with useColorFieldContext().

ColorField.Root

The root container. Owns the color, the field's own numeric and text state, and every operation the other parts invoke through context. Renders a <div> by default.

PropTypeDefaultDescription
valueColor | string | nullControlled color value.
defaultValueColor | string | nullInitial color when uncontrolled.
colorSpaceSpaceId'hsl'The color space the field operates in (e.g. 'hsl', 'oklch').
channelstring'h'The channel this field controls, or 'alpha'. Not read when format is 'hex'.
format'number' | 'degree' | 'percentage' | 'hex'AutoDerived from the channel config when omitted; 'hex' is never derived and switches the field to editing the whole color.
minnumberAutoMinimum value, in display units. Falls back to the channel config, then 0.
maxnumberAutoMaximum value, in display units. Falls back to the channel config, then 0xffffff in hex mode and 100 otherwise.
stepnumberAutoStep for arrow keys and the steppers. Falls back to the channel config, then 1.
disabledbooleanfalseDisables interaction.
readOnlybooleanfalseShows the value but refuses edits.
onValueChange(color: Color) => voidCalled on every change, including mid-typing.
onValueCommit(color: Color) => voidCalled once at the end of an interaction: blur, Enter, an arrow key, or a stepper press.
asReact.ElementType'div'The element or component to render as.
classNamestringClass applied to the rendered element.
styleReact.CSSPropertiesInline styles applied to the rendered element.
childrenReact.ReactNodeThe field's parts.

WARNING

Neither colorSpace nor channel accepts 'hex' as a color space: SpaceId has no such member. Hex editing is format="hex".

With neither value nor defaultValue, the root holds no color: the input renders empty, and edits have nothing to rebuild from, so nothing is emitted. Give it at least a defaultValue.

ColorField.Input

The editable text surface. Renders an <input type="text" role="spinbutton"> and owns the keyboard map, the blur/Enter commit, and the select-on-focus behaviour.

It is a spinbutton rather than type="number" because the field renders suffixed text, 210°, 50%, #ff8800, that a numeric input would reject.

Extends ComponentPropsWithoutRef<"input">; it declares no props of its own.

The part sets value, disabled, readOnly, autoComplete="off", autoCorrect="off", spellCheck={false} and inputMode="text" itself. It does not generate an accessible name, so pass your own aria-label. Every attribute it sets is written before your props are spread, so anything you pass wins.

ColorField.Increment

Steps the value up by step. Renders a <button type="button"> with tabIndex={-1}. The input owns the field's tab stop, so the steppers are pointer affordances only.

Holding the button repeats: one step immediately, a 400ms pause, then a step every 60ms. The release listeners live on window, so a pointer that leaves the button before lifting still ends the hold.

Extends ComponentPropsWithoutRef<"button">; it declares no props of its own.

The button computes disabled as "the root is disabled or read-only, or the value already sits at max", and defaults aria-label to "Increase". Both are written before your props are spread, so passing your own overrides the rendered attribute, though the internal pointer guard still refuses to step past max.

ColorField.Decrement

Steps the value down by step. Identical to ColorField.Increment in every respect except direction: it disables at min and defaults aria-label to "Decrease".

Extends ComponentPropsWithoutRef<"button">; it declares no props of its own.

ColorField.Swatch

A read-only preview of a color. Delegates to ColorSwatch with as="span", rendering role="img" with the color painted as a flat linear-gradient layered over a checkerboard, so a translucent value shows the checks through it.

Extends ColorSwatchProps, which is Omit<ComponentPropsWithoutRef<"div">, "value"> plus:

PropTypeDefaultDescription
valueColor | string | nullThe color to display. Independent of the root's value.
checkerSizenumber16The checkerboard tile size in pixels.
alphabooleanfalseWhen true, reflects the color's alpha; when false, paints the color opaque.
disabledbooleanfalseOnly meaningful inside a ColorSwatchGroup; a field swatch has no interaction of its own.
asReact.ElementType'span'The element or component to render as.

The swatch publishes --urcolor-swatch-color, --urcolor-swatch-color-opaque, --urcolor-swatch-alpha and --urcolor-swatch-checkerboard as custom properties for callers styling their own overlays. Your own style object is merged last, so it wins.

Data Attributes

AttributePartPresent when
data-disabledRoot, InputThe root is disabled.
data-readonlyRoot, InputThe root is read-only.
data-disabledIncrement, DecrementThe root is disabled or read-only, or the value already sits at that button's bound.
data-pressedIncrement, DecrementThe button is held down.

Accessibility

ColorField exposes the input as the single tab stop. The steppers carry tabIndex={-1} on purpose: everything they do is reachable from the keyboard through the input's own arrow-key map, so they add no keyboard surface a screen reader user has to walk past.

ARIA Labels

AttributeDescription
role="spinbutton"Applied to ColorField.Input.
aria-valuenowThe current value in display units. Absent while the field is empty.
aria-labelNot generated for the input. Supply your own. Defaults to "Increase" / "Decrease" on the steppers.
role="img"Applied to ColorField.Swatch.
disabled / readOnlyNative properties, mirrored onto the input from the root.

aria-valuemin and aria-valuemax are not emitted by this package; the range still governs clamping, snapping and the steppers' disabled state.

Keyboard Navigation

KeyAction
Arrow UpIncrease by one step
Arrow DownDecrease by one step
Page UpIncrease by 10 steps
Page DownDecrease by 10 steps
HomeJump to minimum
EndJump to maximum
EnterCommit the current value

Every one of those keys commits, so onValueCommit fires per press. Blurring the input commits too. Typing fires onValueChange on each keystroke that parses, and the text you typed is only clamped, snapped and reformatted at commit time.