Skip to content
garn

NumberField

A typed numeric input — Intl-formatted (decimal/currency/percent/unit) with a stepper, spinbutton a11y, and a raw number value.

Stablev0.4.2added in v0.2.0@garn/ui/number-field
On this page

Default

A labelled quantity field with a floor of 0.

Currency

Currency formatting via formatOptions.

Percent

Percent style — the value is the fraction, displayed as a percentage.

Bounded

min/max clamp; the stepper disables at each bound.

States

Disabled and invalid.

Affordances

clearable and loading — both render to the left of the stepper, which always stays pinned at the trailing edge.

Appearances

outline · soft · ghost — the shared field-appearance axis, inherited from Input.

Sizes

xs · sm · md · lg · xl.

Installation

garn is copy-in — the garn CLI writes this component's source into your project, so you own and can edit it. It resolves any base files and installs the npm dependencies below for you.

terminal
npx garn-ui add number-field
First time? Set up garn in your project
Registry
number-field
Deps
lucide-react
Registry deps
use-controllable-stateuse-merged-refnumber-parseinput

Import

import { NumberField } from "@garn/ui/number-field";

Props

The component's public props and their types.

appearance
Description
No description.
Type
"ghost" | "outline" | "soft"
Default
outline
size
Description
No description.
Type
"lg" | "md" | "sm" | "xl" | "xs"
Default
md
decrementLabel
Description
Accessible name for the decrement button.
Type
string
Default
"Decrement"
defaultValue
Description
Initial value for the uncontrolled case.
Type
number
formatOptions
Description
`Intl.NumberFormatOptions` — decimal · currency · percent · unit.
Type
Intl.NumberFormatOptions
hideStepper
Description
Hide the +/− stepper buttons (keyboard stepping still works).
Type
boolean
Default
false
incrementLabel
Description
Accessible name for the increment button.
Type
string
Default
"Increment"
largeStep
Description
PageUp/PageDown increment. Defaults to `step * 10`.
Type
number
locale
Description
BCP-47 locale for formatting/parsing. Defaults to the runtime locale.
Type
string
max
Description
Upper bound — clamped on blur/step; increment disables at it.
Type
number
min
Description
Lower bound — clamped on blur/step; decrement disables at it.
Type
number
onValueChange
Description
Fires with the committed number (or `undefined` when cleared).
Type
(value: number | undefined) => void
step
Description
ArrowUp/ArrowDown increment. Defaults to 0.01 for percent, else 1.
Type
number
value
Description
The numeric value. Controlled via `value` + `onValueChange`.
Type
number

Plus 312 inherited native <input> attributes.

Styling

Target these data-slots and states, and remap these tokens, to restyle without forking the component.

[data-slot="input"][data-slot="input-root"][data-slot="number-field-stepper"][data-slot="number-field-increment"][data-slot="number-field-decrement"]

States

SelectorState
:hoverhover
:focusfocus
:focus-visiblefocus-visible
[aria-invalid=true]invalid
[disabled]disabled
[data-slot=number-field-increment][disabled]at-bound
:hover, :focus-withinghost-stepper-reveal
PropertyTokenTier
height--garn-control-h-mdsemantic · air
paddingInline--garn-control-px-mdsemantic · air

When to use

Enter a quantity, price, percentage, or measurement by typing or stepping — with locale-correct formatting and a real number value.

Reach for something else when

  • A code/identifier that isn't a quantity (use Input)
  • a coarse value best dragged within a range (use Slider)
  • a one-time-code (use InputOTP).

Overview

NumberField is the typed numeric control — the number-side sibling of TimeField. It reuses Input wholesale (the wrapped focus surface, appearance × size, leading slot, and the invalid treatment), adding a trailing +/− stepper and the number logic. Under the hood it is a type="text" input with role="spinbutton" — never native type="number" — so it can format the value with Intl.NumberFormat (formatOptions: decimal, currency, percent, unit) while keeping the raw string editable while focused. The value is a real number: min/max clamp on blur, an explicit step snaps to the grid on blur, and stepping (↑/↓, PageUp/Down, Home/End, or the buttons) is float-safe and clamped.

Reach for a plain Input when the value is a code or identifier rather than a quantity, and a Slider when a coarse value is best chosen by dragging within a fixed range.

Guidelines

Pair with a Label and pass formatOptions that matches the quantity (currency, percent, a unit) — it drives both display and parsing. Set min/max to clamp and to disable the stepper at each bound, and an explicit step when the value should snap to a grid (percent defaults to 0.01). Read the value as a number (percent as its fraction); on form submit a hidden input carries the raw number. Let the field surface own sizing — pick a size rung rather than hand-padding.

Best practices

Do
  • Pair with a label and pass formatOptions for currency/percent/unit.
  • Set min/max (and step when snapping) to constrain the value.
  • Hold the value in state (or defaultValue) and read it as a number.
Don't
  • Don't reach for native <input type="number"> — it can't format and has unstyleable steppers.
  • Don't hand-set height/padding — the size variant owns the grid.
  • Don't rely on the placeholder as the label.

Content guidelines

  • Label with the quantity ("Price", "Guests").
  • Match formatOptions to the unit users expect (a currency, a percent, a unit).

Troubleshooting

A percent field's value looks 100× too small (0.2, not 20).

Cause. Percent style stores the fraction; Intl renders 0.2 as "20%".

Fix. Treat the value as a fraction (0.2 = 20%); multiply by 100 only for your own display outside the field.

Typed decimals get rounded on blur.

Cause. An explicit step turns on snap-to-grid on blur.

Fix. Drop step (or widen it) if you want free decimal entry; step is opt-in for plain numbers.

The form submits a formatted string like "$1,499.99".

Cause. That's the display; the value layer is separate.

Fix. Pass name — NumberField renders a hidden input carrying the raw number.

The stepper arrows are invisible on a ghost field until you interact with it.

Cause. By design — appearance="ghost" is a seamless field that only surfaces on hover/focus, so the stepper follows suit.

Fix. This is expected; keyboard stepping (↑/↓) still works even when the buttons are hidden. Use outline or soft if the stepper should always be visible.

Accessibility

Role
spinbutton
Focus
Inherits Input's focus surface (border darkens on focus; keyboard focus adds a neutral ring).

Accessibility requirements

warn

Give every number field an accessible label.

when no associated <label htmlFor>, aria-label, or aria-labelledby

Keyboard

ArrowUpArrowDownStep the value by `step`.
PageUpPageDownStep the value by `largeStep` (default `step` × 10).
HomeEndJump to `min` / `max` (when set).
A1.3.1Info and RelationshipsA2.1.1KeyboardAA2.4.7Focus VisibleA4.1.2Name, Role, Value
  • The field is role=spinbutton with aria-valuenow / aria-valuemin / aria-valuemax and an aria-valuetext carrying the formatted display.
  • Give the field an accessible label — <label htmlFor>, aria-label, or aria-labelledby.
  • The stepper buttons are out of the tab order (keyboard users step with the arrows); they stay labelled for pointer + screen-reader users.
  • Reflect validation with aria-invalid + a described error message.