1
ComponentsForm Controls

Number Field

Numeric input with keyboard entry, step controls, range constraints, formatting and optional scrubbing.

Purpose

Use Number Field for numeric values that benefit from keyboard entry, step buttons, optional scrubbing and explicit range or formatting rules.

Stepped number

import { NumberField, NumberFieldDecrement, NumberFieldGroup, NumberFieldIncrement, NumberFieldInput } from "@/components/ui/number-field";

export function Example() {
  return (
    <NumberField defaultValue={12} min={0}>
      <NumberFieldGroup>
        <NumberFieldDecrement />
        <NumberFieldInput aria-label="Seats" />
        <NumberFieldIncrement />
      </NumberFieldGroup>
    </NumberField>
  );
}

Installation

With the Siteplane registry alias configured, add the component and import the installed source from your app.

CLI

pnpm dlx shadcn@4.16.1 add @siteplane/number-field

Import

import { NumberField } from "@/components/ui/number-field";
import { CursorGrowIcon, NumberFieldDecrement, NumberFieldGroup, NumberFieldIncrement, NumberFieldInput, NumberFieldScrubArea } from "@/components/ui/number-field";
import { Field, FieldDescription, FieldError, FieldLabel } from "@/components/ui/field";
import { Form } from "@/components/ui/form";
import { Label } from "@/components/ui/label";

Need the machine-readable source metadata? View registry JSON.

Usage

Use Number Field for numeric input with increment, decrement and optional scrub controls.

import { NumberField, NumberFieldDecrement, NumberFieldGroup, NumberFieldIncrement, NumberFieldInput } from "@/components/ui/number-field";

export function Example() {
  return (
    <NumberField defaultValue={12} min={0}>
      <NumberFieldGroup>
        <NumberFieldDecrement />
        <NumberFieldInput aria-label="Seats" />
        <NumberFieldIncrement />
      </NumberFieldGroup>
    </NumberField>
  );
}

Examples

Sizes, states, range and step limits, formatted values and form integration are props and compositions of the same primitive.

Sizes

<NumberField size="sm">...</NumberField>
<NumberField>...</NumberField>
<NumberField size="lg">...</NumberField>
<NumberField size="xl">...</NumberField>

States

{/* Default */}
<NumberField defaultValue={0}>
  <NumberFieldGroup>
    <NumberFieldDecrement />
    <NumberFieldInput aria-label="Quantity" />
    <NumberFieldIncrement />
  </NumberFieldGroup>
</NumberField>

{/* Disabled */}
<NumberField defaultValue={42} disabled>
  <NumberFieldGroup>
    <NumberFieldDecrement />
    <NumberFieldInput aria-label="Disabled quantity" />
    <NumberFieldIncrement />
  </NumberFieldGroup>
</NumberField>

{/* Invalid */}
<NumberField defaultValue={0}>
  <NumberFieldGroup aria-invalid>
    <NumberFieldDecrement />
    <NumberFieldInput aria-label="Invalid quantity" />
    <NumberFieldIncrement />
  </NumberFieldGroup>
</NumberField>

{/* Read only: value stays visible, steppers do nothing */}
<NumberField defaultValue={12} readOnly>
  <NumberFieldGroup>
    <NumberFieldDecrement />
    <NumberFieldInput aria-label="Read only quantity" />
    <NumberFieldIncrement />
  </NumberFieldGroup>
</NumberField>

Min, max and step

{/* Clamped range */}
<NumberField defaultValue={5} max={10} min={0}>
  <NumberFieldGroup>
    <NumberFieldDecrement />
    <NumberFieldInput aria-label="Range quantity" />
    <NumberFieldIncrement />
  </NumberFieldGroup>
</NumberField>

{/* Coarse and fine steps with a draggable scrub label */}
<NumberField defaultValue={0} step={10}>
  <NumberFieldScrubArea label="Step 10" />
  <NumberFieldGroup>
    <NumberFieldDecrement />
    <NumberFieldInput />
    <NumberFieldIncrement />
  </NumberFieldGroup>
</NumberField>

<NumberField defaultValue={0} step={0.1}>
  <NumberFieldScrubArea label="Step 0.1" />
  <NumberFieldGroup>
    <NumberFieldDecrement />
    <NumberFieldInput />
    <NumberFieldIncrement />
  </NumberFieldGroup>
</NumberField>

Formatted value

<NumberField defaultValue={49} format={{ currency: "USD", style: "currency" }}>
  <NumberFieldGroup>
    <NumberFieldDecrement />
    <NumberFieldInput aria-label="Price" />
    <NumberFieldIncrement />
  </NumberFieldGroup>
</NumberField>

Form integration

1 to 100 units.

Quantity: not submitted
<Form onSubmit={onSubmit}>
  <Field name="quantity">
    <FieldLabel>Quantity</FieldLabel>
    <NumberField defaultValue={1} max={100} min={1}>
      <NumberFieldGroup aria-invalid={Boolean(error) || undefined}>
        <NumberFieldDecrement />
        <NumberFieldInput />
        <NumberFieldIncrement />
      </NumberFieldGroup>
    </NumberField>
    {error ? (
      <FieldError>{error}</FieldError>
    ) : (
      <FieldDescription>1 to 100 units.</FieldDescription>
    )}
  </Field>
  <Button loading={loading} type="submit">Submit</Button>
</Form>

External label

The root id is forwarded to the input, so an external Label can target it with htmlFor.

const id = useId();

<div className="grid gap-2">
  <Label htmlFor={id}>Quantity</Label>
  <NumberField defaultValue={0} id={id}>
    <NumberFieldGroup>
      <NumberFieldDecrement />
      <NumberFieldInput />
      <NumberFieldIncrement />
    </NumberFieldGroup>
  </NumberField>
</div>

Rolling digits

Optional variant: only the digits that change roll vertically on increment and decrement.

const [value, setValue] = useState<number | null>(12);
const [previousValue, setPreviousValue] = useState<number | null>(null);
const [rollDirection, setRollDirection] = useState<"down" | "up">("up");
const [rollKey, setRollKey] = useState(0);

function handleValueChange(nextValue: number | null, details) {
  if (nextValue !== value) {
    setPreviousValue(value);
    setRollDirection(
      details.reason === "decrement-press"
        ? "down"
        : details.reason === "increment-press"
          ? "up"
          : (nextValue ?? 0) >= (value ?? 0)
            ? "up"
            : "down",
    );
    setRollKey((key) => key + 1);
    setValue(nextValue);
  }
}

const display = value === null ? "" : String(value);
const previousDisplay = previousValue === null ? "" : String(previousValue);

<NumberField locale="en-US" onValueChange={handleValueChange} value={value}>
  <NumberFieldGroup>
    <NumberFieldDecrement />
    <span className="relative min-w-0 grow overflow-hidden">
      {/* The real input stays focusable; its text is transparent. */}
      <NumberFieldInput
        aria-label="Animated quantity"
        className="relative z-1 text-transparent caret-foreground"
      />
      {/* Mirror renders one span per character and rolls changed digits. */}
      <span
        aria-hidden="true"
        className="siteplane-number-field-value-window"
        data-roll-direction={rollDirection}
      >
        {display.split("").map((character, index) => {
          const changed =
            rollKey > 0 && (previousDisplay[index] ?? "") !== character;

          return (
            <span
              className="relative inline-block h-full overflow-hidden"
              key={`${index}-${character}`}
            >
              {changed && previousDisplay[index] ? (
                <span
                  className="siteplane-number-field-value"
                  data-roll="out"
                  key={`previous-${rollKey}-${index}`}
                >
                  {previousDisplay[index]}
                </span>
              ) : null}
              <span
                className="siteplane-number-field-value"
                data-roll={changed ? "in" : "idle"}
              >
                {character}
              </span>
            </span>
          );
        })}
      </span>
    </span>
    <NumberFieldIncrement />
  </NumberFieldGroup>
</NumberField>

API Reference

The reference lists the source-owned exports and props that are easy to miss in visual examples. Inherited Base UI props remain available unless the wrapper narrows them.

  • NumberField owns the value and accepts the Base UI number-field props plus size="sm" | "default" | "lg" | "xl".
  • size="xl" gives the composed group the shared 56px customer-facing outer height and 16px value text. Use the surrounding provider's radius="sm" personality for the global 8px main corner.
  • Compose NumberFieldGroup, NumberFieldInput, NumberFieldDecrement and NumberFieldIncrement for the standard control.
  • NumberFieldScrubArea adds a labelled drag target for changing the value; CursorGrowIcon is the matching visual indicator.
  • Configure min, max, step, format, controlled value or uncontrolled defaultValue on NumberField, not on the input slot.

Motion

Component motion uses the shared Siteplane motion contract. See the global motion guide for provider setup, tokens and reduced-motion behavior.

  • Number Field adds no root-level enter or exit animation. Button feedback, focus, disabled and invalid states come from the native Siteplane primitives.
  • Scrubbing updates the value directly without moving the surrounding layout. The optional Rolling Digits documentation example is not part of the default primitive and reduces its digit transition to one frame in system and provider reduced-motion modes.

Accessibility

  • Provide a visible label through FieldLabel or an explicit accessible name.
  • Give NumberFieldScrubArea a label that describes the value it changes.
  • Keep increment, decrement and keyboard behavior on the Base UI-backed slots, and pair invalid state with a visible FieldError.

Implementation Guidance

  • Use Input for free-form text and Slider for visual range selection.
  • Do not rebuild a numeric input from raw browser controls when Number Field fits.
  • Keep range, step and formatting rules on the root so every input method shares one contract.

On This Page