Pho Design System

Number Field

A numeric input with stepper buttons, keyboard step, and pointer scrubbing. Built on Base UI NumberField, which handles clamping and locale-aware formatting. Use it whenever the value is a number within a sensible range.

Import

import { NumberField } from "@photon-ai/pho-ui/components/number-field";

Basic

Set defaultValue (or value), plus min / max / step on NumberField.Root. Compose the Group with a Decrement, Input, and Increment. Typed and stepped values clamp to the range.

import { IconMinus, IconPlus } from "@tabler/icons-react";

<NumberField.Root defaultValue={3} min={0} max={10}>
  <NumberField.Group>
    <NumberField.Decrement aria-label="Decrease">
      <IconMinus />
    </NumberField.Decrement>
    <NumberField.Input />
    <NumberField.Increment aria-label="Increase">
      <IconPlus />
    </NumberField.Increment>
  </NumberField.Group>
</NumberField.Root>;

Formatting

Pass format — a standard Intl.NumberFormatOptions object — to render the value as currency, a percentage, or with grouping separators. The Input still edits the raw number; formatting is applied on display and on blur. locale overrides the runtime locale.

<NumberField.Root
  defaultValue={1500}
  step={100}
  min={0}
  format={{ style: "currency", currency: "USD" }}
>
  <NumberField.Group>
    <NumberField.Decrement aria-label="Decrease">
      <IconMinus />
    </NumberField.Decrement>
    <NumberField.Input className="w-28" />
    <NumberField.Increment aria-label="Increase">
      <IconPlus />
    </NumberField.Increment>
  </NumberField.Group>
</NumberField.Root>

Scrubbing

Wrap a label in NumberField.ScrubArea to make it a drag handle — press and drag horizontally to change the value, the way a design tool adjusts opacity or size. Add NumberField.ScrubAreaCursor to show a virtual cursor while the pointer is locked. Point the label’s htmlFor at the input’s id so a click still focuses the field.

import { IconArrowsLeftRight } from "@tabler/icons-react";

<NumberField.Root id={id} defaultValue={50} min={0} max={100}>
  <NumberField.ScrubArea>
    <label
      htmlFor={id}
      className="text-pho-secondary cursor-ew-resize text-base"
    >
      Opacity
    </label>
    <NumberField.ScrubAreaCursor>
      <IconArrowsLeftRight className="size-4" />
    </NumberField.ScrubAreaCursor>
  </NumberField.ScrubArea>
  <NumberField.Group>{/* Decrement / Input / Increment */}</NumberField.Group>
</NumberField.Root>;

Disabled and read-only

Set disabled on NumberField.Root to make the whole field inert — the input and both steppers ignore interaction and dim. Set readOnly to keep the value focusable and selectable but not editable; typing and stepping do nothing while the field stays in the Tab order.

Disabled
Read-only
<NumberField.Root defaultValue={3} min={0} max={10} disabled>
  {/* Group / Decrement / Input / Increment */}
</NumberField.Root>

<NumberField.Root defaultValue={3} min={0} max={10} readOnly>
  {/* Group / Decrement / Input / Increment */}
</NumberField.Root>

Anatomy

Props

Set on NumberField.Root:

Set on NumberField.ScrubArea:

Accessibility

NumberField.Input renders a native <input> (exposed as a textbox) with inputMode="numeric" and aria-roledescription="Number field"; it reflects field validity through aria-invalid. The role description is not a name — give the field an accessible name with a Field.Label or an aria-label.

The stepper buttons ship with default aria-labels ("Increase" / "Decrease") and aria-controls pointing at the input, and they carry tabIndex={-1} so they stay out of the Tab order — keyboard users change the value from the input itself. They remain reachable to touch screen readers.

Keyboard (focus in the input):

Best practices