Light

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

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.
  • 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
  • Label association automatic via FormLabel and FormControl

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