f-ui
Components

Number Input

Numeric field with optional end or split steppers, typed decimals by default, and a headless React Aria hook.

Number Input is a controlled number | null field for quantities, scores, and other non-money numerics. Default chrome puts steppers in a trailing column (controls="end"); opt into split ± buttons or controls={false} for type-heavy ranges. Values commit through React Aria with commitBehavior="validate" so decimals such as 0.05 survive when step is 1.

For money amounts with an ISO currency selector, use Currency Input instead.

When To Use

  • Capture a plain number (quantity, count, score, percent) where number | null is the form value.
  • Prefer controls="end" (default) in Ant-density forms so the field keeps a standard input width.
  • Use controls="split" for small bounded counts (guests, seats) where ± is the primary interaction.
  • Use controls={false} for large free-range typing where steppers add noise.
  • surface="tableCell" defaults to controls={false} (Excel / AG Grid / Ant table number cells). Pass controls="end" to opt steppers back in. Do not put a second border on the input — Editable Table owns one field box. The field inherits the cell's alignment (text-end on the root for numeric columns) so typing matches display; form surfaces stay start-aligned.
  • Use Currency Input for money; do not overload Number Input with currency formatOptions.

Interactions

EventBehavior
Type digits / decimalsField stays editable (type="text"); no native spinner.
Blur / EnterCommits parsed value; default commitBehavior="validate" keeps typed decimals (e.g. 0.05) even when step is 1. Opt into snap for integer-only fields that should snap to step on commit.
ArrowUp / ArrowDownStep by step (default 1); clamped to min / max when set.
Home / EndJump to min / max when defined.
Stepper clickSame as keyboard step; steppers stay out of tab order (tabIndex={-1}).
At min / maxMatching stepper is disabled (visible, not removed).
Mouse wheelIgnored (isWheelDisabled).
Empty commitEmits null (not NaN).

Default display is ungrouped (formatOptions.useGrouping: false). Pass formatOptions for grouping, percent, or units when needed.

Installing

pnpm dlx shadcn@latest add https://ui.isaacfei.com/r/number-input.json
npx shadcn@latest add https://ui.isaacfei.com/r/number-input.json
yarn dlx shadcn@latest add https://ui.isaacfei.com/r/number-input.json
bun x shadcn@latest add https://ui.isaacfei.com/r/number-input.json

Or with a namespace: npx shadcn@latest add @f-ui/number-input.

The CLI installs @react-aria/button, @react-aria/i18n, @react-aria/numberfield, @react-stately/numberfield, and lucide-react, and pulls field-control-shell and field-disabled-surface via registryDependencies.

Usage

import { NumberInput } from "@/components/f-ui/number-input/number-input";

<NumberInput
  value={qty}
  onValueChange={setQty}
  min={0}
  aria-label="Quantity"
/>

Examples

Default End Steppers

Controlled field with the default trailing chevron column. Type a value or use the steppers; the readout below shows the committed number | null.

value: 12

"use client";

import { useState } from "react";

import { NumberInput } from "@/components/f-ui/number-input/number-input";

export function NumberInputDemo() {
  const [value, setValue] = useState<number | null>(12);

  return (
    <div className="max-w-xs space-y-2">
      <NumberInput
        value={value}
        onValueChange={setValue}
        aria-label="Quantity"
      />
      <p className="text-muted-foreground text-xs tabular-nums">
        value: {value ?? "null"}
      </p>
    </div>
  );
}

Split Controls

controls="split" with min={0} and max={99} for guest-count style UIs. The field is centered; ± buttons sit on either side.

value: 2

"use client";

import { useState } from "react";

import { NumberInput } from "@/components/f-ui/number-input/number-input";

export function NumberInputSplitDemo() {
  const [value, setValue] = useState<number | null>(2);

  return (
    <div className="max-w-xs space-y-2">
      <NumberInput
        controls="split"
        min={0}
        max={99}
        value={value}
        onValueChange={setValue}
        aria-label="Guest count"
      />
      <p className="text-muted-foreground text-xs tabular-nums">
        value: {value ?? "null"}
      </p>
    </div>
  );
}

No Controls

controls={false} for type-heavy or wide-range values where steppers would get in the way.

value: 1250

"use client";

import { useState } from "react";

import { NumberInput } from "@/components/f-ui/number-input/number-input";

export function NumberInputNoControlsDemo() {
  const [value, setValue] = useState<number | null>(1250);

  return (
    <div className="max-w-xs space-y-2">
      <NumberInput
        controls={false}
        value={value}
        onValueChange={setValue}
        aria-label="Score"
      />
      <p className="text-muted-foreground text-xs tabular-nums">
        value: {value ?? "null"}
      </p>
    </div>
  );
}

Min and Max Bounds

At the lower bound the decrement stepper disables; at the upper bound the increment stepper disables. Values outside the range clamp on commit.

At 0 the decrement stepper is disabled; at 5 the increment stepper is disabled. Try the steppers or type a value outside the range and blur.

value: 0

"use client";

import { useState } from "react";

import { NumberInput } from "@/components/f-ui/number-input/number-input";

export function NumberInputMinMaxDemo() {
  const [value, setValue] = useState<number | null>(0);

  return (
    <div className="max-w-xs space-y-2">
      <NumberInput
        min={0}
        max={5}
        value={value}
        onValueChange={setValue}
        aria-label="Bounded quantity"
      />
      <p className="text-muted-foreground text-xs">
        At 0 the decrement stepper is disabled; at 5 the increment stepper is
        disabled. Try the steppers or type a value outside the range and blur.
      </p>
      <p className="text-muted-foreground text-xs tabular-nums">
        value: {value ?? "null"}
      </p>
    </div>
  );
}

Headless Usage

useNumberInput is the view-model: inputProps, stepper button props, and numberValue. The demo wires native <input> / <button> only — no f-ui classNames or stock parts.

value: 3

"use client";

import { useState } from "react";

import { useNumberInput } from "@/components/f-ui/number-input/use-number-input";

export function NumberInputHeadlessDemo() {
  const [value, setValue] = useState<number | null>(3);
  const {
    inputRef,
    groupProps,
    inputProps,
    incrementButtonProps,
    decrementButtonProps,
    numberValue,
  } = useNumberInput({
    value,
    onValueChange: setValue,
    min: 0,
    max: 10,
    "aria-label": "Headless quantity",
  });

  return (
    <div>
      <div {...groupProps}>
        <button type="button" {...decrementButtonProps}>

        </button>
        <input ref={inputRef} {...inputProps} />
        <button type="button" {...incrementButtonProps}>
          +
        </button>
      </div>
      <p>value: {numberValue ?? "null"}</p>
    </div>
  );
}

Composition

NumberInput
└── root (div)
    └── NumberInputGroup
        ├── [split] NumberInputStepperButton (−)
        ├── NumberInputField
        ├── [split] NumberInputStepperButton (+)
        └── [end] stepper column
            ├── NumberInputStepperButton (↑)
            └── NumberInputStepperButton (↓)

With controls={false}, only NumberInputField renders inside the group.

Formily

Prefer FormField kind="number" — that is the first-class path (same registry as Editable Table field: true). It wires value / onValueChange as number | null (empty and clear commit null, never a mid-edit string). The Open default commitBehavior="validate" keeps typed decimals such as 0.05 when step is 1.

<FormField name="qty" kind="number" label="Qty" componentProps={{ min: 0, step: 1 }} />

Do not use kind="text" / connected Input with type: "number". Schema JSON must set x-component: "NumberInput" (Formily type: "number" alone does not pick the control). Raw Formily Field wiring remains available for fully custom layouts.

API Reference

Props

PropTypeDefaultDescription
valuenumber | nullControlled value.
defaultValuenumber | nullUncontrolled initial value.
onValueChange(value: number | null) => voidFires on commit (blur / step). Empty → null.
min / maxnumberInclusive bounds; disable matching steppers at edges.
stepnumber1Step size for steppers and Arrow keys.
formatOptionsIntl.NumberFormatOptions{ useGrouping: false }Display / parse options.
commitBehavior"validate" | "snap""validate"validate keeps typed decimals when step is coarse (e.g. 0.05 with step: 1). snap snaps commits to step (Aria default; use for integer-only fields).
controls"end" | "split" | false"end" on default/compact; false on tableCellStepper layout.
surface"default" | "compact" | "tableCell""default"Shell density. tableCell is borderless; the table decorator owns the field box.
placeholderstringField placeholder.
disabled / readOnlybooleanfalse
id / namestring
aria-label / aria-labelledby / aria-describedbystring
aria-invalidboolean
onFocus / onBlurfocus handlers
classNamestringOuter root.
classNamesPartial<Record<NumberInputSlot, string>>Per-slot overrides.

Slots

SlotApplied to
rootOuter wrapper
groupBordered NumberInputGroup shell
fieldEditable NumberInputField
incrementIncrement stepper button
decrementDecrement stepper button

Hook — useNumberInput(options)

Same value / bound / format / commit options as the container (without controls, surface, classNames). Returns:

ReturnRole
inputRefAttach to your <input>
numberValueCommitted number | null
canIncrement / canDecrementBound state for custom chrome
groupPropsSpread on the wrapping group element
inputPropsSpread on <input> (type="text")
incrementButtonProps / decrementButtonPropsSpread on stepper buttons (tabIndex={-1}, type="button")

On this page