Photon 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

  • NumberField.Root — owns value, min, max, step, and formatting
  • NumberField.Group — the bordered surface around the parts
  • NumberField.Decrement / .Increment — stepper buttons
  • NumberField.Input — the editable value
  • NumberField.ScrubArea — optional drag-to-change region
  • NumberField.ScrubAreaCursor — optional virtual cursor shown while scrubbing

Props

Set on NumberField.Root:

  • value — number | null — the controlled numeric value
  • defaultValue — number — uncontrolled initial value
  • onValueChange — (value, eventDetails) => void — fires as the value changes
  • onValueCommitted — (value, eventDetails) => void — fires when the value is committed (blur, or pointer release after scrub/press)
  • min / max — number — clamp range for steps and typed values
  • step — number | "any" (default 1) — increment for buttons, arrow keys, and scrub
  • smallStep — number (default 0.1) — step while the Alt key is held
  • largeStep — number (default 10) — step while the Shift key is held
  • snapOnStep — boolean (default false) — snap to multiples of the step
  • allowOutOfRange — boolean (default false) — let typed values exceed min/max without clamping
  • allowWheelScrub — boolean (default false) — scrub with the mouse wheel while focused
  • format — Intl.NumberFormatOptions — display formatting (currency, percent, grouping)
  • locale — Intl.LocalesArgument — locale for formatting; defaults to the runtime locale
  • disabled — boolean (default false) — ignore all interaction
  • readOnly — boolean (default false) — allow focus but not editing
  • required — boolean (default false) — value required before form submit
  • name — string — form field name for the hidden input
  • id — string — id applied to the input element

Set on NumberField.ScrubArea:

  • direction — horizontal · vertical (default horizontal) — axis the cursor scrubs along
  • pixelSensitivity — number (default 2) — pixels of movement per value change
  • teleportDistance — number — distance before the locked cursor loops back to center

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):

  • Arrow Up / Arrow Down — step by step
  • Shift + Arrow — step by largeStep
  • Alt/Option + Arrow — step by smallStep
  • Home — jump to min (when min is set)
  • End — jump to max (when max is set)

Best practices

  • Prefer a number field over a plain Input whenever the value is numeric — it brings steppers, clamping, keyboard step, and number-pad keyboards for free.
  • Always set min/max so the steppers and typed values stay in range, and so Home/End can jump to the bounds.
  • The steppers are already labelled ("Increase" / "Decrease") and skipped by Tab; override the aria-labels only to localize or clarify, not to re-add what’s already there.
  • Use format for currency and percentages instead of decorating the value yourself — the input keeps editing the raw number and formats on blur.