Number Field
Locale-aware numeric input on Base UI's NumberField in Input's field chrome — Intl formatting, min/max/step, keyboard stepping, wheel scrub.
- Status
- Since
0.4.0- Accessibility pattern
- APG spinbutton
Last updated
Install
Add Number Field from the VegaStack registry. The CLI verifies the item's integrity hash before writing it.
pnpm dlx shadcn@latest add @vegastack/number-fieldUsage
import { NumberField } from "@/components/ui/number-field";
<NumberField aria-label="Quantity" defaultValue={2} min={0} max={99} />;NumberField wraps Base UI's NumberField: locale-aware parsing and formatting
(format: Intl.NumberFormatOptions + locale), min/max/step with
snapOnStep, keyboard stepping, and wheel scrubbing — rendered in
Input's exact addon-group chrome with full-height
− / + steppers.
Like Input, the size prop is the control-height variant on the shared
28/32/40 scale and replaces the native numeric size attribute.
What lives elsewhere, and why:
| Behaviour | Where it lives |
|---|---|
| Minor-units (cents) handling | Your field layer — the component works in display units |
| Pointer scrubbing (ScrubArea) | Compose BaseNumberField.ScrubArea yourself — no keyboard/touch parity |
| Labels, descriptions, errors | Field around the control |
| Free-text digits (phone, ids) | Input — formatting semantics would fight them |
Examples
Money
Money is a format prop, not a separate component:
format={{ style: "currency", currency: "USD" }}. The currency-code
Select drops into the suffix slot.
Money is a format prop — the currency Select sits in the suffix slot.
One recipe belongs to your field layer, not this component: minor units. If your API stores integer cents, convert at the boundary — the component works in display units:
<NumberField
aria-label="Amount"
value={amountInCents / 100}
onValueChange={(v) => setAmountInCents(Math.round((v ?? 0) * 100))}
format={{ style: "currency", currency: "USD" }}
/>Sizes, units, and states
Every size on the shared 28/32/40 scale, with the unit suffix, hideControls, disabled, and the
invalid border tint. Wrap the field in a Field to pair the tint with a
message and the invalid shake.
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
aria-label | string | — | Accessible name for the numeric input. Required in practice unless a
wrapping Field/aria-labelledby supplies one — the input must never be
unnamed. |
className | string | — | Extra classes for the bordered group root. |
hideControls | boolean | false | Hide the − / + stepper buttons. Keyboard stepping (arrows, Home/End) and wheel scrub keep working — the buttons are a pointer affordance only. |
inputClassName | string | — | Classes for the inner <input> element (e.g. text-end for columnar
numbers). |
inputRef | React.Ref<HTMLInputElement> | — | Ref forwarded to the inner <input> element. |
placeholder | string | — | Placeholder for the empty input. |
prefix | React.ReactNode | — | Non-editable addon before the input (a unit, an icon, a currency code) —
Input's addon idiom. Plain strings render as muted, non-selectable text. |
size | "lg" | "md" | "sm" | 'md' | Control height on the shared 28/32/40 scale (--size-sm/md/lg), matching
Input/Button/Select. (The native numeric size attribute is intentionally
replaced by this variant prop, exactly as on Input.) |
suffix | React.ReactNode | — | Non-editable addon after the input. The documented seat for a currency-code
Select in the money recipe. |
Data attributes and CSS variables on NumberField
| Attribute | Values |
|---|---|
data-field-group | "" |
data-size | mirrors a prop or state value |
data-slot | "number-field" | "number-field-decrement" | "number-field-increment" | "number-field-input" | "number-field-prefix" | "number-field-suffix" |
All Base UI NumberField.Root props pass through: format, locale, min,
max, allowOutOfRange, step / largeStep / smallStep, snapOnStep,
allowWheelScrub, readOnly, required, onValueChange, onValueCommitted,
and native form integration via name.
Accessibility
- The inner input must carry an accessible name — pass
aria-label(or label it through a wrappingField). - The − / + steppers are full-height, ≥24px pointer targets with accessible names ("Decrease" / "Increase"); keyboard users step with the arrow keys instead, so the buttons add no tab stops beyond Base UI's defaults.
- Focus uses the text-entry affordance: the group border tints on focus-within,
matching
Input's addon mode.
| Key | Action |
|---|---|
| ↑ / ↓ | Step by step. |
| Shift + ↑/↓ | Step by largeStep. |
| Alt + ↑/↓ | Step by smallStep. |
| Home / End | Jump to min / max when set. |
| Contract | States tested |
|---|---|
| Behaviour | default, stepping, clamped, disabled, read-only, invalid |
| Accessibility | keyboard, labeled, semantic-html |
| Visual | default, focus, invalid, disabled, dark |
Do / Don't
OTP Input
A multi-slot one-time-passcode input — keyboard navigation, paste distribution, autofill, masking, and a disabled state, built on Base UI OTP Field.
Chip Input
Free-token entry — Enter or comma commits, delimited pastes split into chips, and per-chip validation keeps invalid entries visible instead of dropping them.