Form
Build forms with react-hook-form, connected labels, validation and submission feedback.
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, FormSelect, FormSelectOption, FormSubmit } from '@youj/react/form'
Installation & usage
npm i @youj/react react-hook-formFor an app already set up with npx @youj/cli init, which installs the peers (@youj/core, @youj/motion, motion and Base UI) and wires the provider. New app? Start at Installation.
Requires react-hook-form, an optional dependency, and the examples below also use zod and @hookform/resolvers (pnpm add react-hook-form zod @hookform/resolvers). Import it from @youj/react/form.
Form builds on react-hook-form. Validate with any resolver: zodResolver(schema) from @hookform/resolvers is the usual one.
Use Form when you need validation, such as in settings, sign-in or a create flow. It connects labels and errors, focuses the first invalid control, and shows submission states in the button. For a field without validation, wrap an Input in a Field.
Install what it needs first, or the build fails on a missing export:
npm i react-hook-form @hookform/resolvers zodIn the Next.js App Router, render this from a Client Component, a file that starts with 'use client': it takes functions (onSubmit and a resolver), and a Server Component can't pass those. Server rendering has the rest.
import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { FieldGroup, Input } from '@youj/react'
import { Form, FormField, FormSubmit } from '@youj/react/form'
const schema = z.object({ name: z.string().trim().min(3, 'Use at least 3 characters.') })
export function RenameProject() {
const form = useForm({ resolver: zodResolver(schema), defaultValues: { name: 'Orion' }, mode: 'onTouched' })
return (
<Form form={form} onSubmit={(values) => api.rename(values)}>
<FieldGroup>
<FormField name="name" label="Project name" required>
{(field) => <Input {...field} />}
</FormField>
<FormSubmit successLabel="Saved">Save</FormSubmit>
</FieldGroup>
</Form>
)
}Form takes the useForm() return value and an onSubmit that receives the resolver's output. Resolve for success, and reject or throw for failure. FormField is a Field around one controlled value: its label names the control, its description and error describe it, and the control is marked invalid while the error shows. FormSubmit is an Async Button that validates and then runs onSubmit.
The parts are shadcn's: FormField, FormItem, FormLabel, FormControl, FormDescription and FormMessage. The shorthand above lays them out for you; to place them yourself, or to move a shadcn form over, give FormField a render (see shadcn's parts). The difference from shadcn's Form: Form takes the useForm() return value as form and an onSubmit, and renders the <form> itself, so there is no <form onSubmit={form.handleSubmit(…)}> to write.
shadcn's parts
Give FormField a render and it only provides the field to its parts, as shadcn's does. FormItem is a Field: it groups the label, control, description and message. FormControl marks its control invalid, FormMessage shows the field's error and useFormField() reads the name, validity and error for a part of your own.
import { Input } from '@youj/react'
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, FormSubmit } from '@youj/react/form'
<Form form={form} onSubmit={(values) => api.rename(values)}>
<FormField
name="name"
render={({ field }) => (
<FormItem required>
<FormLabel>Project name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>Shown on the dashboard.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormSubmit successLabel="Saved">Save</FormSubmit>
</Form>The label and description props and the children function are the shorthand for exactly this: a FormItem with a FormLabel, your control, a FormDescription and a FormMessage. Use render when you want a different order, or no label.
Select
FormSelect is a FormField with a Select in it, built from options. It wires the field's value, change, blur and focus target for you, and takes the same name, label, description and required. An empty select holds null, so a required one refines it away in the schema (see Required pickers with zod).
import { Form, FormSelect } from '@youj/react/form'
const regions = [
{ value: 'fra', label: 'Frankfurt' },
{ value: 'iad', label: 'Washington, D.C.' },
{ value: 'sin', label: 'Singapore', disabled: true },
]
<FormSelect name="region" label="Region" options={regions} placeholder="Choose a region" required />For grouped or custom items, compose a Select inside FormField and map the parts as below: value, onValueChange and items on Select, and ref and onBlur on SelectTrigger.
Checkbox and Radio Group
A Checkbox holds a boolean and a Radio Group a string, and both take a value and a change handler instead of an event. Map value to checked for the checkbox, and onChange to onCheckedChange and onValueChange. Start the checkbox at false in defaultValues; an unchosen radio group holds undefined.
import { Checkbox, Field, FieldLabel, RadioGroup, RadioGroupItem } from '@youj/react'
<FormField name="terms" label="Terms" required>
{(field) => (
<Checkbox ref={field.ref} checked={field.value} onCheckedChange={field.onChange} onBlur={field.onBlur} />
)}
</FormField>
<FormField name="plan" label="Plan" required>
{(field) => (
<RadioGroup ref={field.ref} value={field.value} onValueChange={field.onChange} onBlur={field.onBlur}>
<Field orientation="horizontal">
<RadioGroupItem value="free" />
<FieldLabel>Free</FieldLabel>
</Field>
<Field orientation="horizontal">
<RadioGroupItem value="pro" />
<FieldLabel>Pro</FieldLabel>
</Field>
</RadioGroup>
)}
</FormField>Required pickers with zod
An empty picker or select holds null, so the form's values (the schema's input) allow null while the submitted values (its output) don't. Two things make TypeScript see that:
- Refine with a type guard.
(value): value is string => value !== nullnarrows the output tostring. A refine that only returns a boolean keepsstring | null. - Give
useFormthe input and the output. Let it infer both fromzodResolver(schema), or name all three generics:useForm<z.input<typeof schema>, unknown, z.output<typeof schema>>.useForm<z.infer<typeof schema>>names only the output, sodefaultValues: { region: null }and the resolver no longer type-check.
import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { FieldGroup } from '@youj/react'
import { Combobox } from '@youj/react/command'
import { Form, FormField, FormSelect, FormSubmit } from '@youj/react/form'
const chosen = (value: string | null): value is string => value !== null
const schema = z.object({
region: z.string().nullable().refine(chosen, 'Choose a region.'),
owner: z.string().nullable().refine(chosen, 'Choose an owner.'),
})
export function CreateService() {
// Values hold null while empty (the input); onSubmit gets strings (the output).
const form = useForm<z.input<typeof schema>, unknown, z.output<typeof schema>>({
resolver: zodResolver(schema),
defaultValues: { region: null, owner: null },
mode: 'onTouched',
})
return (
<Form form={form} onSubmit={(values) => api.createService(values)}>
<FieldGroup>
<FormSelect name="region" label="Region" options={regions} placeholder="Choose a region" required />
<FormField name="owner" label="Owner" required>
{(field) => (
<Combobox ref={field.ref} options={owners} value={field.value} onValueChange={field.onChange} onBlur={field.onBlur} />
)}
</FormField>
<FormSubmit successLabel="Created">Create service</FormSubmit>
</FieldGroup>
</Form>
)
}values in onSubmit is { region: string; owner: string }, and an empty field shows its refine message.
Description
A project form with a name and a URL, validated with zod as each field is touched, and a Save button that shows the submission.
Clear the name in the demo and press Save. The error grows in under the field, focus lands on it, and the button shakes to "Check the form". Enter in any field presses FormSubmit, so the keyboard gets the same states as a click, and a second press while the submission runs is ignored.
On an invalid submit, focus moves to the first invalid control in document order and scrolls it to the middle of the view: smoothly, or at once under reduced motion. mode: 'onTouched' validates each field as it's left, which is the gentlest default for long forms.
A rule only the server knows, such as a URL that's already taken, comes back as a field error. Set it with form.setError inside onSubmit, then throw: the message appears on its field, focus moves there, and the button shows its errorLabel. Type "taken" in the demo's URL field to see it. An onSubmit that throws without setting a field error shakes the button to errorLabel and leaves the fields as they were.
onSubmit={async (values) => {
const result = await api.save(values)
if (result.error === 'slug_taken') {
form.setError('slug', { message: `orion.dev/${values.slug} is already in use.` })
throw new Error('slug_taken')
}
}}Pickers mapped onto a form field.
FormField passes its render function react-hook-form's field. Spread it onto an Input. The pickers take value and onValueChange instead of an event, so map the parts: ref, value, onValueChange={field.onChange} and onBlur. Empty pickers hold null, so a required one refines it away in the schema.
<FormField name="owner" label="Owner" required>
{(field) => (
<Combobox ref={field.ref} options={owners} value={field.value} onValueChange={field.onChange} onBlur={field.onBlur} />
)}
</FormField>Passing ref matters: it's how the form finds the control to focus when it's invalid.
Code
form-demo.tsx, the Demo story. The component itself comes from the package.
Reference
Propsform, onSubmit, name… (30+)
Props
| Prop | Type | |
|---|---|---|
formRequired | UseFormReturn<T, Context, Output>The useForm() return value. | – |
onSubmitRequired | (values: Output) => unknownRuns with the resolver's output (a zod schema's parsed values). Resolve for success; reject (or throw) for failure. | – |
Other props pass through: extends ComponentProps<'form'>. | ||
FormField
| Prop | Type | |
|---|---|---|
render | neverAllows you to replace the component's HTML element
with a different tag, or compose it with another component.
Accepts a | – |
label | ReactNode | – |
description | ReactNode | – |
children | (field: ControllerRenderProps<T, N>, state: ControllerFieldState) => ReactNodeRenders the control inside the shorthand's label, description and message. Spread | – |
nameRequired | N | – |
control | Control<T>For type inference from useForm(); defaults to the surrounding Form's. | – |
With children it also takes every Field prop except invalid, which it sets from the field's state, such as required, disabled and orientation.
FormItem
| Prop | Type | |
|---|---|---|
orientation | 'horizontal' | 'vertical' | 'responsive' | – |
required | booleanMarks the label and makes the control required. | – |
The other parts (FormLabel, FormControl, FormDescription, FormMessage) take the props of the Field part they are named after.
FormSelect
| Prop | Type | |
|---|---|---|
optionsRequired | ReadonlyArray<FormSelectOption<NonNullable<FieldPathValue<T, N>>>>The choices, in order. The trigger shows the chosen one's label. | – |
placeholder | ReactNodeShown while the value is empty ( | – |
size | 'sm' | 'default'Trigger height. default `default` | `default` |
nameRequired | N | – |
orientation | 'horizontal' | 'vertical' | 'responsive' | – |
label | ReactNode | – |
render | neverAllows you to replace the component's HTML element
with a different tag, or compose it with another component.
Accepts a | – |
required | booleanMarks the label and makes the control required. | – |
description | ReactNode | – |
control | Control<T>For type inference from useForm(); defaults to the surrounding Form's. | – |
It also takes every FormField prop except children. Each option is { value, label, disabled? }.
FormSubmit
| Prop | Type | |
|---|---|---|
invalidLabel | stringShown beside the cross when fields need attention. default "Check the form" | "Check the form" |
variant | ButtonVariant'default''danger''ghost''outline''link''secondary'
| – |
size | 'default' | 'sm' | 'lg' | 'xs' | 'icon' | 'icon-xs' | 'icon-sm' | 'icon-lg' | – |
sound | booleanWith sound on ( default true | true |
pendingLabel | stringShown beside the spinner while the action runs, and announced. Announced as "Working…" when omitted. | – |
successLabel | string | ((result: TResult) => string)Shown beside the check and announced. A function gets what
default "Done" | "Done" |
errorLabel | string | ((error: unknown) => string)Shown beside the cross and announced assertively. A
function gets what default "Failed" | "Failed" |
resetAfter | numberHow long success / error hold before the label returns, in ms. Default: 3 × the deliberate token. | – |
onActionStateChange | (state: AsyncState) => voidFires on every state change: idle → pending → success | error → idle. | – |
hold | boolean | numberHold to confirm: the action runs only once a press is held (a pointer, or
Space or Enter held down) for this long. | – |
FormSubmit takes every Async Button prop except onAction, status and type, including pendingLabel, successLabel, errorLabel and resetAfter. FormInvalidError is the error FormSubmit sees when validation fails, for code that calls the submission itself.
MotionError in, error out, submit pending… · reduced motion
Motion
| Moment | Tokens | ||
|---|---|---|---|
| Error inthe message grows from 0 height and fades in under its control (Field) | duration.baseease.standard220ms · cubic-bezier(0.2, 0, 0, 1) | 220mscubic-bezier(0.2, 0, 0, 1) | |
| Error outthe text fades before the height closes | duration.fastease.exit140ms · cubic-bezier(0.4, 0, 1, 1) | 140mscubic-bezier(0.4, 0, 1, 1) | |
| Submit pendingthe label crossfades to a spinner (Async Button) | duration.fastease.standard140ms · cubic-bezier(0.2, 0, 0, 1) | 140mscubic-bezier(0.2, 0, 0, 1) | |
| Button widthfollows each new label | spring.snappy0.2s, bounce 0 | 0.2s, bounce 0 | |
Submit successa check draws on beside successLabel | duration.slowease.emphasized340ms · cubic-bezier(0.6, 0, 0.1, 1) | 340mscubic-bezier(0.6, 0, 0.1, 1) | |
Invalid or faileda 4, 3 and 2px shake to invalidLabel or errorLabel | duration.slowease.standard340ms · cubic-bezier(0.2, 0, 0, 1) | 340mscubic-bezier(0.2, 0, 0, 1) | |
| Focus first invalidscrolls it to the middle of the view | no token smooth scroll | smooth scroll |
Reduced motion
Errors appear and disappear at once, the button's states swap instantly without a shake, and the first invalid control is scrolled to without smooth scrolling.
Reduced motion
Errors appear and disappear without the height animation. The submit button's states swap at once and don't shake, and focusing the first invalid control scrolls straight to it. Validation, messages and focus behave exactly the same.
AccessibilityKeyboard, ARIA, focus
Accessibility
- The
<form>setsnoValidate: the schema validates, and messages are written in your voice rather than the browser's. - Each
FormFieldlabel names its control, and the description and error are linked to it witharia-describedby. The control carriesaria-invalidwhile its error shows, andrequiredmarks it required: nativerequiredon an Input,aria-requiredon custom controls such as Combobox, MultiSelect, TagInput and TimePicker. - An invalid submit moves focus to the first invalid control, with a visible focus ring, so keyboard and screen reader users land on the problem rather than having to find it.
FormSubmitannounces pending and success politely and failures assertively, as Async Button does. Presses while it runs are ignored.