Forms

Field

A labeled form control: the label, an optional description, the control and its error, wired together with ids that match between the server and the browser. Every Ouro form control takes these props and is built on the same parts.

We send the shopping list here.

In Spoonjoy

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

In its source: app/components/recipe/ManualIngredientInput.tsx · app/components/recipe/ParsedIngredientRow.tsx · app/components/recipe/StepEditorCard.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/field#v1.2.13

Or, with the @ouro namespace set up:

npx shadcn@latest add @ouro/ouro-base @ouro/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/field#v1.2.13 ourostack/ouro-ui/field-tests#v1.2.13
import { Field } from "@/components/ouro/field/field";

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

Example

<Field label="Email" name="email" required description="We never share it." error={errors?.email}>
  <FieldControl type="email" autoComplete="email" />
</Field>
// Helper text after the control: a FieldDescription placed where it shows.
<Field label="Password" name="password" required error={errors?.password}>
  <FieldControl type="password" autoComplete="new-password" />
  <FieldDescription>Use 8 or more characters.</FieldDescription>
</Field>

Usage

Pass error straight from a server action or a client check: it shows the message and sets aria-invalid. The error only shows: it never blocks the form's next submit, so the user can always send the form again (only the browser's own checks, such as required, block). Inside a Form, a field with a name shows the form's error for that name when it has no error of its own. The control is FieldControl, from field-parts.

The order is fixed: label, description, control, error. To put helper text after the control, leave description out and render a FieldDescription after the FieldControl. It is linked to the control (aria-describedby) wherever it sits, and the error still comes last. TextField and the other one-tag controls render their own control, so build that field from Field and FieldControl.

The required marker is drawn with CSS beside the label and is not part of its text, so a test finds the field by the label alone: getByLabelText("Email"), or Playwright's getByLabel("Email", { exact: true }). The marker stays visible in forced colors.

A field's error is the control's description, not an alert, so screen readers read it when focus reaches the field (a Form moves focus to the first invalid one). In a test, assert it with toHaveAccessibleDescription("Enter an email address.") or getByText; getByRole("alert") finds only a Form's message about the whole form.

Use instead when

  • Use Fieldset instead to group several controls under one legend, and the FieldRoot parts when the label, control and error need another layout.

Props

Type: FieldProps.

children (required)
Type: ReactNode
The control: <FieldControl />, or <FieldControl render={<textarea />} /> for another element.
className
Type: string
Layout only: margins, width, grid placement (mt-4, col-span-2). Appearance classes warn in development; style fields in your theme.
description
Type: ReactNode
Supporting text under the label, read after the label through aria-describedby.
disabled
Type: boolean
Disables the control and fades the field.
error
Type: ReadonlyErrorMessages
What is wrong, from your server action or client check. A message (or several, as an array, each shown once as a list item) shows under the control, which gets aria-invalid and points at the message with aria-describedby. A message is text or a React node, so it can carry a link: error={<>Already imported. <Link href="/recipes/soup">Open it</Link></>}. An empty string or array means valid. It only shows: it never blocks the next submit of the form around it.
hideLabel
Type: boolean
Hides the label visually; screen readers still read it as the control's name.
label (required)
Type: string
What the control is for, shown above it and read as its name. Required: every control needs a name. To hide it visually, keep it and add hideLabel.
name
Type: string
The form field name the control submits under. A Form's errors for this name show here when there is no error prop, and go away once the user changes the control.
required
Type: boolean
The control must be filled: it gets required and aria-required, and the label a marker.

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.

  • errorMessage: Not a prop. Ouro UI uses error: <Field error="Required.">.
  • helperText: Not a prop. Ouro UI uses description: <Field description="We never share it.">.
  • htmlFor: Not a prop. Field connects the label to its control itself: <Field label="Email"><FieldControl /></Field>.
  • invalid: Not a prop. error marks the field invalid: <Field error="What is wrong">.
  • isDisabled: Not a prop. Ouro UI uses disabled: <Field disabled>.
  • isInvalid: Not a prop. error marks the field invalid: <Field error="What is wrong">.
  • isRequired: Not a prop. Ouro UI uses required: <Field required>.
  • placeholder: Not a prop, and not a label. Pass label, and a placeholder example on the control: <FieldControl placeholder="you@example.com" />.

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: Field 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.

Parts

When these props cannot express your layout, build it from the parts in @/components/ouro/field/field-parts. The nesting they need follows.

import {
  FieldControl,
  FieldDescription,
  FieldError,
  FieldLabel,
  FieldRoot,
} from "@/components/ouro/field/field-parts";

Required nesting

<FieldRoot name="email" required>
  <FieldLabel>Email</FieldLabel>
  <FieldDescription>We never share it.</FieldDescription>
  <FieldControl type="email" />
  <FieldError />
</FieldRoot>

Each part checks that it sits inside <FieldRoot>: outside it, a part throws in development with a message that shows this JSX shape.field-nesting

FieldControl
What it is: The control, a text <input> by default. It takes the field's id, name, disabled and required, aria-invalid from its error, and aria-describedby from its description and error. Render another element with render={<textarea />}.
FieldDescription
What it is: Supporting text for the control, read after its label through aria-describedby.
FieldError
What it is: The error message, linked to the control through aria-describedby. Without children it shows the browser's validation message, or a Base UI Form error for the field's name; with match and children it always shows them. It fades and slides in.
FieldLabel
What it is: The control's label. It points at the control by id. A required field shows a marker after it, drawn by CSS on the element around the label, so the label's text and the control's name stay exactly what you wrote (screen readers hear "required" from the control). className goes on that element, where [data-ouro-required] is too; hide the label visually with className="sr-only". style, id, data-* attributes and event handlers go on the <label>.
FieldRoot
What it is: The state owner of a field built from parts. It connects the label, description and error to the control, and passes name, disabled and required down. Use the Field component instead unless its props cannot express your layout; every Ouro form control is built on these parts. Parts link the description and error after hydration; use Field when the server HTML must carry them, or pass ids and aria-describedby yourself.

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