Form

Building forms with React Hook Form and Zod.

Spec · from metadata

When to use

  • Any form that needs validation and error handling
  • Multi-field forms with react-hook-form + zod schema validation
  • Forms that need accessible label/input/error message association

When not to use

  • Single uncontrolled input with no validation — use Input + Label directly
  • Search bars or inline filters — use Input directly

Required companions

Environmental prerequisites this component needs beyond its props. Supply these or it won't work as expected — TypeScript won't catch a missing one.

CompanionKindWhy
react-hook-formpeer-depForm is a thin layer over react-hook-form; install it and drive fields with useForm().
@hookform/resolverspeer-depBridges a zod (or other) schema to react-hook-form via zodResolver.
zodpeer-depSchema + validation for the form; paired with @hookform/resolvers.
<Form {...form}> (useForm context)providerForm is a FormProvider — FormField/FormItem must render inside it.
Cardlayout-ancestorStandalone forms are wrapped in Card per the forms pattern.

Anti-patterns

Avoid<form>
  <label>Email</label>
  <input name="email" />
  {errors.email && <span>{errors.email}</span>}
</form>
Prefer<Form {...form}>
  <form onSubmit={form.handleSubmit(onSubmit)}>
    <FormField control={form.control} name="email" render={({ field }) => (
      <FormItem>
        <FormLabel>Email</FormLabel>
        <FormControl><Input {...field} /></FormControl>
        <FormMessage />
      </FormItem>
    )} />
  </form>
</Form>

Always use Form components for automatic ID association, aria attributes, and consistent error display.

Avoid<Form {...form}>
  <form>...</form>
</Form>
Prefer<Card>
  <CardContent>
    <Form {...form}>
      <form>...</form>
    </Form>
  </CardContent>
</Card>

All forms must be wrapped in a Card for consistent visual framing.

Accessibility

  • Screen readerFormControl auto-sets aria-describedby to link to FormDescription and FormMessage. aria-invalid is set when the field has an error. FormMessage takes role=alert while an error is present, so errors appearing on submit are announced and not only readable on focus. That makes FormMessage the owner of the announcement: role=alert is assertive and interrupts, so do not render a form-level Alert error summary at its default role alongside it — N invalid fields plus a summary is N+1 assertive regions firing on one submit. Give the summary role='status' or role={undefined}. Note also that react-hook-form's shouldFocusError defaults to true, so a failed submit focuses the first invalid field and its message is both read on focus via aria-describedby and fired as an alert.
  • ContrastError text uses text-destructive which meets WCAG AA contrast requirements.

Token bindings

TokenCategoryUsage
text-destructivecolorError message and label error state
text-muted-foregroundcolorDescription helper text
gap-2spacingFormItem internal spacing

Import

import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import {
  Form,
  FormControl,
  FormDescription,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from "@timelycare/helix-ui"

Props

interface FormFieldProps {
  name: string
  control: Control<any>
  render: ({ field }) => React.ReactNode
}

interface FormItemProps {
  className?: string
  children: React.ReactNode
}

Form Layouts

LayoutUse ForStructure
Single columnSimple forms, mobilespace-y-4 stacked fields
Two columnComplex forms, desktopgrid grid-cols-2 gap-4 or flex with two columns
Card wrapperStandalone formsCard with Header, Content, Footer

Choosing a Layout

  • Use single column for 1-4 fields or mobile views
  • Use two column for 5+ fields on desktop
  • Always wrap in Card for visual grouping

Field Spacing

ElementSpacingToken
Card padding24pxp-6
Between card sections24pxgap-6
Between fields16pxspace-y-4 or gap-4
Label to input8pxspace-y-2
Input to description8pxBuilt into FormItem
Title to description (header)6pxgap-1.5
Form to footer24pxPart of card structure

Card Constraints

PropertyValue
Max width374px
Min width190px
Border radiusrounded-lg (10px)
Paddingp-6 (24px)

Supported Field Types

Forms commonly include these field components:

  • Input — text fields with label and optional description
  • Select — dropdown selection
  • Combobox — searchable dropdown with optional avatar
  • Textarea — multi-line text input
  • Switch — toggle with label and description
  • Radio Group — single selection from options
  • Checkbox — boolean selection with label and description
  • Date Picker — date selection trigger

Common Patterns

Basic Form

<Card>
  <CardHeader>
    <CardTitle>Sign up</CardTitle>
    <CardDescription>Create your new account.</CardDescription>
  </CardHeader>
  <CardContent className="space-y-4">
    <FormField
      control={form.control}
      name="email"
      render={({ field }) => (
        <FormItem>
          <FormLabel>Email</FormLabel>
          <FormControl>
            <Input placeholder="name@example.com" {...field} />
          </FormControl>
          <FormMessage />
        </FormItem>
      )}
    />
  </CardContent>
  <CardFooter>
    <Button type="submit" className="w-full">Create account</Button>
  </CardFooter>
</Card>

Two Column Form

<CardContent>
  <div className="flex gap-4">
    <div className="flex-1 space-y-4">
      <FormField name="firstName" /* ... */ />
      <FormField name="email" /* ... */ />
    </div>
    <div className="flex-1 space-y-4">
      <FormField name="lastName" /* ... */ />
      <FormField name="phone" /* ... */ />
    </div>
  </div>
</CardContent>

Responsive Two Column

<CardContent>
  <div className="flex flex-col md:flex-row gap-4">
    <div className="flex-1 space-y-4">
      {/* Left column fields */}
    </div>
    <div className="flex-1 space-y-4">
      {/* Right column fields */}
    </div>
  </div>
</CardContent>

With Multiple Actions

<CardFooter className="flex justify-between">
  <Button variant="outline" type="button">Cancel</Button>
  <Button type="submit">Submit</Button>
</CardFooter>

Field with Description

<FormItem>
  <FormLabel>Bio</FormLabel>
  <FormControl>
    <Textarea placeholder="Tell us about yourself" {...field} />
  </FormControl>
  <FormDescription>Max 500 characters.</FormDescription>
  <FormMessage />
</FormItem>

With Switch Field

<FormField
  control={form.control}
  name="notifications"
  render={({ field }) => (
    <FormItem className="flex gap-3 items-start">
      <FormControl>
        <Switch checked={field.value} onCheckedChange={field.onChange} />
      </FormControl>
      <div className="space-y-1.5">
        <FormLabel>Enable notifications</FormLabel>
        <FormDescription>Receive email notifications.</FormDescription>
      </div>
    </FormItem>
  )}
/>

With Checkbox Field

<FormField
  control={form.control}
  name="terms"
  render={({ field }) => (
    <FormItem className="flex gap-2 items-start">
      <FormControl>
        <Checkbox checked={field.value} onCheckedChange={field.onChange} />
      </FormControl>
      <div className="space-y-1.5">
        <FormLabel>Accept terms</FormLabel>
        <FormDescription>I agree to the terms and conditions.</FormDescription>
      </div>
    </FormItem>
  )}
/>

With Radio Group

<FormField
  control={form.control}
  name="type"
  render={({ field }) => (
    <FormItem className="space-y-2">
      <FormLabel>Select type</FormLabel>
      <FormControl>
        <RadioGroup onValueChange={field.onChange} defaultValue={field.value}>
          <div className="flex items-center gap-3">
            <RadioGroupItem value="option1" id="option1" />
            <Label htmlFor="option1">Option 1</Label>
          </div>
          <div className="flex items-center gap-3">
            <RadioGroupItem value="option2" id="option2" />
            <Label htmlFor="option2">Option 2</Label>
          </div>
        </RadioGroup>
      </FormControl>
    </FormItem>
  )}
/>

Accessibility

  • Built on React Hook Form + Radix Form primitives
  • Automatically manages aria-describedby linking fields to error/description
  • Sets aria-invalid on fields with validation errors
  • Screen reader: announces validation errors via FormMessage, which takes role="alert" while an error is present (plain hint content passed as children is not announced)
  • Label association automatic via FormLabel and FormControl

FormMessage owns the announcement

role="alert" is assertive — it interrupts the screen reader. Since every erroring FormMessage takes it, a form with three invalid fields already has three assertive regions firing on one submit. Adding a form-level Alert error summary at its default role makes four, and simultaneous assertive regions are queued or dropped depending on the screen reader.

If you render a summary, hand it a quieter role — Alert spreads props after its own role, so this works:

<Alert variant="destructive" role="status">
  <CircleX />
  <AlertTitle>Could not save</AlertTitle>
  <AlertDescription>Fix the 3 highlighted fields and try again.</AlertDescription>
</Alert>

Use role="status" when the summary adds something the field messages don't (a server-side failure reason), and role={undefined} when it only repeats them.

Worth knowing: react-hook-form's shouldFocusError defaults to true, so a failed submit focuses the first invalid field. That message is read via aria-describedby on focus and fires as an alert, so the first error is announced twice. That is inherent to the fix that makes on-submit errors audible at all, and is preferable to the previous behaviour, where an error appearing while focus sat on the submit button was announced by nothing.


Gotchas

ProblemSolution
Validation not showingWrap input in FormControl, add FormMessage
Submit not workingWrap entire form in Form component with onSubmit
Fields not registeringPass {...field} spread to input components
Two columns on mobileAdd flex-col md:flex-row for responsive
Switch/Checkbox alignmentUse flex items-start gap-3 for horizontal layout
Card too wideApply max-w-[374px] to constrain width

See Also


Last updated: February 9, 2026