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-monoreponpx shadcn@latest add ourostack/ouro-ui/ouro-base#v1.2.13Then 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.13Or, with the @ouro namespace set up:
npx shadcn@latest add @ouro/ouro-base @ouro/number-fieldIts 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.13import { 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
TextFieldwithinputMode="numeric"instead for digit codes such as a PIN or a ZIP code, andSliderwhen 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;
nullis 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.NumberFormatoptions:{ style: "currency", currency: "USD" }shows$4.50,{ style: "percent" }shows15%,{ 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
formatand 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
nullwhen 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;
nullis 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 useserror:<NumberField error="Required.">.formatOptions: Not a prop. Ouro UI usesformat:<NumberField format={{ style: "percent" }}>.helperText: Not a prop. Ouro UI usesdescription:<NumberField description="...">.invalid: Not a prop.errormarks the field invalid:<NumberField error="What is wrong">.isDisabled: Not a prop. Ouro UI usesdisabled:<NumberField disabled>.isInvalid: Not a prop.errormarks the field invalid:<NumberField error="What is wrong">.isReadOnly: Not a prop. Ouro UI usesreadOnly:<NumberField readOnly>.isRequired: Not a prop. Ouro UI usesrequired:<NumberField required>.maxValue: Not a prop. Ouro UI usesmax:<NumberField max={10}>.minValue: Not a prop. Ouro UI usesmin:<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 useTextField.
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
- layout-classes: test:registry/ouro/components/ouro/number-field/number-field.test.tsx::NumberField > lets app layout classes win on the root
verify:testExists(registry/ouro/components/ouro/number-field/number-field.test.tsx::NumberField > lets app layout classes win on the root)