Number Field
Locale-aware numeric input on Base UI's NumberField in upstream's InputGroup chrome — Intl formatting, min/max/step, keyboard stepping, wheel scrub.
- Status
- Since
0.4.0- Accessibility pattern
- APG spinbutton
Last updated
Install
Configure registry credentials first using the Quickstart. Before copying this item or any transitive registry dependency, verify the signed manifest and save the exact bytes for Number Field before installation. Retain the digest printed by this command.
pnpm exec vegastack-design verify number-fieldInstall with the pinned shadcn CLI. Bare shadcn does not verify VegaStack signature or integrity.
pnpm dlx shadcn@4.21.0 add @vegastack/number-fieldRun the exact offline --post-write command printed by the preflight, using its saved item path and independently retained --expected-integrity digest. Use the saved preflight for every transitive registry dependency too; stop on any mismatch before using the components.
The same command installs the registry items it composes: @vegastack/input-group.
Usage
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.
Everything it renders is imported, not restated:
InputGroup is the bordered box — border, focus
border, invalid hairline, disabled wash — InputGroupInput is
Input flattened into it, prefix and suffix are
InputGroupAddon slots, and each − / + stepper is an InputGroupButton, which
is Button in its ghost variant.
The one thing this component adds is the layout: the steppers flank the field
at its full height rather than stacking as half-height spinners, so each pointer
target clears 24px inside a 32px control without an invisible hit area. There is
no control-height scale — the box is InputGroup's 32px, and a caller who needs
another height passes a height class in className.
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" }}
/>Units and states
A percent format, the unit suffix, hideControls, disabled, and the invalid destructive border.
Wrap the field in a Field to pair that border with a message. Click or
Tab into a field to see the focus state: the box's border darkens subtly and nothing fills; the
invalid field keeps its destructive border while focused.
Inside a Field
Inside a Field the inner <input> — never the group — takes the label,
the rendered description and error ids, and aria-invalid. Outside one, aria-describedby,
aria-labelledby and id also land on the input.
Whole units, up to 99.
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
aria-describedby | string | — | Ids of the elements that describe the value — an error or a hint. They land on the inner
<input>, which is what a screen reader announces, never on the group. Inside a Field the
rendered FieldDescription/FieldError ids are added for you. |
aria-invalid | "false" | "grammar" | "spelling" | "true" | boolean | — | Marks the value invalid. It lands on the inner <input> — never the group — and upstream's
InputGroup paints its invalid hairline from that descendant. |
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. |
aria-labelledby | string | — | Ids of the elements that name the value. Lands on the inner <input>, like aria-label.
id (inherited from Base UI's root) already targets the input. |
className | string | — | Extra classes for the InputGroup 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),
rendered in an upstream InputGroupAddon. Plain strings read as muted,
non-selectable text. |
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-slot | "number-field" | "number-field-decrement" | "number-field-increment" | "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: while the input inside it has focus,
the
InputGroupbox's border darkens subtly (border-ring/50, easing in over 150ms) and no background changes — text entry's exception to FOC-14 (MK 2026-09-27). An invalid field keeps its destructive border while focused. The steppers are buttons: they keep the global:focus-visiblebackground tint, pulled inside the clipping box (FOC-9).
| 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
Textarea
A styled native textarea for multi-line text — it grows with its content, and signals focus with a subtle darker border rather than a ring.
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.