Forms

NumberField

A labeled number input on the Field contract: pass label, and error from your server action or client check. It parses what people type in their locale, shows the value with format (currency, percent, units), steps with its buttons and the arrow keys, and changes the value when the label is dragged sideways with a mouse. The steppers take taps over 44px on touch screens. The form submits the plain number.

In Spoonjoy

Spoonjoy, a recipe app, is built with Ouro HX (how).

See it live, no sign-in needed: A recipe · Test kitchen

In its source: app/components/cook/RecipeCookView.tsx · app/components/recipe/RecipeHeader.tsx · app/routes/test-kitchen.tsx

Install

Once per app, with Tailwind CSS v4 and an @/* path alias (Ouro HX is built on Base UI, so --base base; skip -t in an existing app):

npx shadcn@latest init --base base --preset nova --yes --no-monorepo
npx shadcn@latest add ourostack/ouro-ui/ouro-base#v1.2.13

Then the component. From v0.8.0 it installs only the helpers it imports and never touches your stylesheet (Installing):

npx shadcn@latest add ourostack/ouro-ui/number-field#v1.2.13

Or, with the @ouro namespace set up:

npx shadcn@latest add @ouro/ouro-base @ouro/number-field

Its tests, stories and Playwright spec are optional. They need Vitest 4.1.11 or later, and from v0.8.0 never change a version your app has (Testing):

npx shadcn@latest add ourostack/ouro-ui/number-field#v1.2.13 ourostack/ouro-ui/number-field-tests#v1.2.13
import { NumberField } from "@/components/ouro/number-field/number-field";

Paths use shadcn's default @/ alias; the CLI rewrites them to yours.

Example

<NumberField label="Servings" name="servings" defaultValue={4} min={1} max={24} />
<NumberField label="Price" name="price" min={0} format={{ style: "currency", currency: "USD" }} step={0.5} />

Usage

It picks the phone keyboard itself: the keypad with a decimal separator when min is 0 or more and the field takes fractions (a fractional step, or a format with fraction digits, such as a currency), the digits-only keypad when min is 0 or more and it takes whole numbers, and the full keyboard when it takes negatives, since iOS's keypads have no minus key.

Use instead when

  • Use TextField with inputMode="numeric" instead for digit codes such as a PIN or a ZIP code, and Slider when an approximate value in a range is enough.

Props

Type: NumberFieldProps.

className
Type: string
Layout only: margins, width, grid placement (mt-4, col-span-2). Appearance classes warn in development; style fields in your theme.
decrementLabel
Type: string
Default: "Decrease"
The name of the button that lowers the value, as visually hidden text and not an aria-label, so a test's label lookup for the field finds the field alone. Translate it with your app.
defaultValue
Type: number | null
The starting value, when the field keeps its own; null is empty.
description
Type: ReactNode
Supporting text under the label, read after the label through aria-describedby.
disabled
Type: boolean
Disables the field and its steppers, and fades it.
error
Type: ReadonlyErrorMessages
What is wrong, from your server action or client check. The message (or several, as a list) shows under the field, which gets aria-invalid. A message is text or a React node (one with a link); an empty string or array means valid.
format
Type: Intl.NumberFormatOptions
How the number shows, as Intl.NumberFormat options: { style: "currency", currency: "USD" } shows $4.50, { style: "percent" } shows 15%, { maximumFractionDigits: 2 } rounds. The form still submits the plain number.
hideLabel
Type: boolean
Hides the label visually; screen readers still read it as the field's name.
incrementLabel
Type: string
Default: "Increase"
The name of the button that raises the value, as visually hidden text and not an aria-label. Translate it with your app.
label (required)
Type: string
What the number is, shown above the field and read as its name. Required: every control needs a name. To hide it visually, keep it and add hideLabel. Drag it sideways with a mouse to change the value.
largeStep
Type: number
Default: 10
The step for Page Up, Page Down and Shift with an arrow key.
locale
Type: Intl.LocalesArgument
Default: the browser's
The locale for format and for parsing what people type.
max
Type: number
The highest value; the steppers stop there.
min
Type: number
The lowest value; the steppers stop there. A minimum of 0 or more also gives phones a number keypad: iOS's keypads have no minus key, so a field that takes negatives (no min, or a negative one) gets the full keyboard.
name
Type: string
The form field name the number submits under, as a plain number (4.5, not $4.50).
onValueChange
Type: ((value: number | null) => void)
Called with the new number as it changes, or null when the field is emptied.
onValueCommitted
Type: ((value: number | null) => void)
Called when the value settles: on blur after typing, when a stepper or scrub is released, and with each key press. Use it to save.
placeholder
Type: string
An example shown while the field is empty. It is not a label; pass label.
readOnly
Type: boolean
People can read and copy the value but not change it.
ref
Type: Ref<HTMLInputElement>
The input element.
required
Type: boolean
The field must be filled: it gets aria-required, and the label a marker.
step
Type: number
Default: 1
The amount the steppers, the arrow keys and a scrub change the value by.
value
Type: number | null
The value, when you control it; null is empty.

Don't use

These are not props. Passing one fails the type check with the fix in the error, and in development a console message names the fix.

  • currency: Not a prop. Currency is a format option: <NumberField format={{ style: "currency", currency: "USD" }}>.
  • errorMessage: Not a prop. Ouro UI uses error: <NumberField error="Required.">.
  • formatOptions: Not a prop. Ouro UI uses format: <NumberField format={{ style: "percent" }}>.
  • helperText: Not a prop. Ouro UI uses description: <NumberField description="...">.
  • invalid: Not a prop. error marks the field invalid: <NumberField error="What is wrong">.
  • isDisabled: Not a prop. Ouro UI uses disabled: <NumberField disabled>.
  • isInvalid: Not a prop. error marks the field invalid: <NumberField error="What is wrong">.
  • isReadOnly: Not a prop. Ouro UI uses readOnly: <NumberField readOnly>.
  • isRequired: Not a prop. Ouro UI uses required: <NumberField required>.
  • maxValue: Not a prop. Ouro UI uses max: <NumberField max={10}>.
  • minValue: Not a prop. Ouro UI uses min: <NumberField min={1}>.
  • onChange: Not a prop. NumberField reports the number itself: <NumberField onValueChange={(value) => …}>.
  • precision: Not a prop. Rounding is a format option: <NumberField format={{ maximumFractionDigits: 2 }}>.
  • type: Not a prop. NumberField is always a number field; for text use TextField.

className is for layout only

Pass margins, width and grid placement (mt-4, w-full, col-span-2). Your classes always win over Ouro UI's: NumberField merges className last with cn (tailwind-merge), so a class that conflicts with its own replaces it.layout-classes

Appearance comes from props, not classes. In development, a class that sets appearance logs one warning per component instance, naming the prop to use instead. A class sets appearance when, after its variants (hover:, sm:) and any ! are removed, it starts with bg-, text-, border-, rounded-, shadow-, font-, ring-, from-, via-, to-, fill- or stroke-, or is border, rounded, shadow or ring, or has a dark: variant. Text wrapping, overflow and table layout classes (text-wrap, text-ellipsis, border-collapse) are layout and do not warn.

Alignment classes (text-center) warn too: set alignment on a parent element.

Types

ErrorMessage

One error message: text, or a React element for a message that carries a link or other markup.

type ErrorMessage = string | ReactElement;

ReadonlyErrorMessages

A read-only message view accepted by fields and Form; mutable input arrays remain valid.

type ReadonlyErrorMessages = ErrorMessage | readonly ErrorMessage[];

Claim sources