Skip to content

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-form

For 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 zod

In 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 !== null narrows the output to string. A refine that only returns a boolean keeps string | null.
  • Give useForm the input and the output. Let it infer both from zodResolver(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, so defaultValues: { 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.

Reference

Propsform, onSubmit, name… (30+)

Props

Form props
PropType
formRequiredUseFormReturn<T, Context, Output>

The useForm() return value.

–
onSubmitRequired(values: Output) => unknown

Runs 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

FormField props
PropType
rendernever

Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a ReactElement or a function that returns the element to render.

–
labelReactNode–
descriptionReactNode–
children(field: ControllerRenderProps<T, N>, state: ControllerFieldState) => ReactNode

Renders the control inside the shorthand's label, description and message. Spread field onto an Input; map it (value, onValueChange={field.onChange}, onBlur, ref) onto the pickers.

–
nameRequiredN–
controlControl<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

FormItem props
PropType
orientation'horizontal' | 'vertical' | 'responsive'–
requiredboolean

Marks 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

FormSelect props
PropType
optionsRequiredReadonlyArray<FormSelectOption<NonNullable<FieldPathValue<T, N>>>>

The choices, in order. The trigger shows the chosen one's label.

–
placeholderReactNode

Shown while the value is empty (null).

–
size'sm' | 'default'

Trigger height.

default `default`

`default`
nameRequiredN–
orientation'horizontal' | 'vertical' | 'responsive'–
labelReactNode–
rendernever

Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a ReactElement or a function that returns the element to render.

–
requiredboolean

Marks the label and makes the control required.

–
descriptionReactNode–
controlControl<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

FormSubmit props
PropType
invalidLabelstring

Shown beside the cross when fields need attention.

default "Check the form"

"Check the form"
variantButtonVariant
'default''danger''ghost''outline''link''secondary'

default | secondary | outline | ghost | danger | link.

–
size'default' | 'sm' | 'lg' | 'xs' | 'icon' | 'icon-xs' | 'icon-sm' | 'icon-lg'–
soundboolean

With sound on (YoujProvider’s sound), the button plays tap as it's pressed. false keeps this one silent.

default true

true
pendingLabelstring

Shown beside the spinner while the action runs, and announced. Announced as "Working…" when omitted.

–
successLabelstring | ((result: TResult) => string)

Shown beside the check and announced. A function gets what onAction resolved with: successLabel={(user) => Saved ${user.name}}. Under status there is no outcome to read, so pass a string.

default "Done"

"Done"
errorLabelstring | ((error: unknown) => string)

Shown beside the cross and announced assertively. A function gets what onAction rejected or threw with, whatever that is: errorLabel={(error) => error instanceof Error ? error.message : 'Failed'}. Under status, pass a string (errorLabel={mutation.error?.message}).

default "Failed"

"Failed"
resetAfternumber

How long success / error hold before the label returns, in ms. Default: 3 × the deliberate token.

–
onActionStateChange(state: AsyncState) => void

Fires on every state change: idle → pending → success | error → idle.

–
holdboolean | number

Hold to confirm: the action runs only once a press is held (a pointer, or Space or Enter held down) for this long. true takes twice the deliberate motion token; a number is milliseconds. The button fills from its inline start while held and springs back if let go early; a full hold runs the action and the button carries on through pending to success or error. It is described as "Hold to confirm".

–

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

Motion tokens
MomentTokens
Error inthe message grows from 0 height and fades in under its control (Field)
duration.baseease.standard

220ms · cubic-bezier(0.2, 0, 0, 1)

220mscubic-bezier(0.2, 0, 0, 1)
Error outthe text fades before the height closes
duration.fastease.exit

140ms · 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.standard

140ms · cubic-bezier(0.2, 0, 0, 1)

140mscubic-bezier(0.2, 0, 0, 1)
Button widthfollows each new label
spring.snappy

0.2s, bounce 0

0.2s, bounce 0
Submit successa check draws on beside successLabel
duration.slowease.emphasized

340ms · 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.standard

340ms · 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> sets noValidate: the schema validates, and messages are written in your voice rather than the browser's.
  • Each FormField label names its control, and the description and error are linked to it with aria-describedby. The control carries aria-invalid while its error shows, and required marks it required: native required on an Input, aria-required on 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.
  • FormSubmit announces pending and success politely and failures assertively, as Async Button does. Presses while it runs are ignored.