Skip to content
Component installs need the registry setup
VegaStack Design

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
stable
Since
0.4.0
Accessibility pattern
APG spinbutton

Last updated

Quantity

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-field

Install with the pinned shadcn CLI. Bare shadcn does not verify VegaStack signature or integrity.

pnpm dlx shadcn@4.21.0 add @vegastack/number-field

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

BehaviourWhere it lives
Minor-units (cents) handlingYour field layer — the component works in display units
Pointer scrubbing (ScrubArea)Compose BaseNumberField.ScrubArea yourself — no keyboard/touch parity
Labels, descriptions, errorsField 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.

Deal amount

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.

kg

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

PropTypeDefaultDescription
aria-describedbystring—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-labelstring—Accessible name for the numeric input. Required in practice unless a wrapping Field/aria-labelledby supplies one — the input must never be unnamed.
aria-labelledbystring—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.
classNamestring—Extra classes for the InputGroup root.
hideControlsbooleanfalseHide the − / + stepper buttons. Keyboard stepping (arrows, Home/End) and wheel scrub keep working — the buttons are a pointer affordance only.
inputClassNamestring—Classes for the inner <input> element (e.g. text-end for columnar numbers).
inputRefReact.Ref<HTMLInputElement>—Ref forwarded to the inner <input> element.
placeholderstring—Placeholder for the empty input.
prefixReact.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.
suffixReact.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

AttributeValues
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 wrapping Field).
  • 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 InputGroup box'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-visible background tint, pulled inside the clipping box (FOC-9).
KeyAction
↑ / ↓Step by step.
Shift + ↑/↓Step by largeStep.
Alt + ↑/↓Step by smallStep.
Home / EndJump to min / max when set.
ContractStates tested
Behaviourdefault, stepping, clamped, disabled, read-only, invalid
Accessibilitykeyboard, labeled, semantic-html
Visualdefault, focus, invalid, disabled, dark

Do / Don't

Do
Express money, percents, and units through the Intl format prop — one numeric input, locale-correct everywhere.
Don't
Build a separate money-input or hand-parse currency strings out of a text Input — parsing is exactly what this component owns.

On this page