Skip to content

Form

<Form> is a thin layer on top of react-hook-form that ties <FormLabel>, <FormControl>, <FormDescription>, and <FormMessage> together with the right ids and aria-describedby references. Use it for every multi-field form.

import from "@na/ui/components/form" ▶ Open in Storybook packages/ui/src/components/form.tsx

When to use

  • Any form with two or more fields.
  • Any field that needs validation, an error message, or a description.

Don’t use Form for: a single-field “search” input (just use Input), inline-edit per-property fields (InlineEditField), or wizards with branching logic (use Stepper — planned).

Basic

The display name shown to your team.

import { useForm } from 'react-hook-form';
import {
Form, FormField, FormItem, FormLabel,
FormControl, FormDescription, FormMessage,
} from '@na/ui/components/form';
const form = useForm<AgentValues>({ defaultValues: { name: '', description: '' } });
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Acme Corp" {...field} />
</FormControl>
<FormDescription>The display name shown to your team.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* …more FormFields… */}
<Button type="submit">Save</Button>
</form>
</Form>

How the parts compose

Each <FormField> provides a context. Inside, the slot wrappers wire:

  • <FormLabel> → renders a <Label> with htmlFor set to the input’s id.
  • <FormControl> → forwards the right id, aria-describedby (linking the description and message), and aria-invalid.
  • <FormDescription> → renders a muted helper paragraph.
  • <FormMessage> → renders the field’s validation error (from react-hook-form), or the children when there’s no error.

You write the React markup; the wiring is handled.

API

Form

Re-exported FormProvider from react-hook-form. Spread the useForm() return value onto it.

FormField

Prop Type Default Description
control * Control Pass form.control.
name * string Field name within the form values type.
render * (props: { field, fieldState, formState }) => ReactNode Render the input. Spread `field` onto your input component.
rules object react-hook-form validation rules.
defaultValue unknown Field-level default (overrides form-level).

FormItem

A <div> wrapper that owns the per-field grid + spacing. Wrap each FormField’s render output in <FormItem>.

FormLabel

Composes Label — applies htmlFor and an error-state color when validation fails.

FormControl

Composes the input via Radix Slot. Forwards the right id, aria-describedby, and aria-invalid.

FormDescription

A muted <p> rendered above the message slot. Tied to the input via aria-describedby.

FormMessage

A destructive <p> rendered below. Renders the validation error message when present, otherwise its children, otherwise nothing.

useFormField

Hook returning { id, name, formItemId, formDescriptionId, formMessageId, error, … } for advanced custom layouts.

Design guidelines

✓ Do

  • Use Form for multi-field forms. The id wiring + aria are not optional.
  • Validate with react-hook-form rules or a resolver (zod, yup). FormMessage picks up errors automatically.
  • Pair every field with a FormDescription when the label alone leaves room for ambiguity.

✗ Don't

  • Forget to render <FormMessage />. Without it the user sees errors only via the destructive ring.
  • Spread {...field} onto a wrapper instead of the input. The field props belong on the input itself.
  • Use Form for inline-edit fields. That's a different primitive — InlineEditField.

Accessibility

  • Every form field is fully wired: <input> gets id, <label> gets htmlFor, error and description are linked via aria-describedby, invalid state sets aria-invalid.
  • Use a single <form onSubmit> per <Form>. Submit on Enter inside any input is automatic.
  • Disable Submit while async validation is pending — keep the user from re-submitting.

▶ Open Form stories in Storybook