Components

Form

A thin, accessible layer over react-hook-form that wires labels, controls, descriptions, and validation messages together with the correct ARIA relationships.

Import

This binding lives behind its own entry, @comitor/ui/form, and is not re-exported from the main barrel. The reason is not bundle size: react-hook-form keeps a singleton React context, so if your app and the package resolve two different copies, useFormContext() reads the wrong provider and returns undefined. Keeping it behind a separate door makes RHF an optional peer that apps without forms never have to install.

tsx
// The react-hook-form binding lives behind its own entry.
import {
  Form,
  FormControl,
  FormDescription,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
  useFormField,
} from '@comitor/ui/form'

// Everything you put *inside* a field still comes from the main entry.
import { Button, Input, Textarea } from '@comitor/ui'

Basic usage

Spread the useForm() result onto Form, then drive each input through a FormField render prop.

This is your public display name.

Validation

Pass rules to a field and submit empty — FormMessage surfaces the error and the control flips to its invalid state.

Colour note: the error label and message are tinted with text-destructive-ink, not text-destructive. --destructive is the background token — the fill behind a delete button — and its job is to carry white foreground text, so in the high-contrast palette it moves darker (#C93B3B), which is the wrong direction for text on the page. --destructive-ink is the on-page text step: it aliases --destructive in the default palette (#D64545, 4.38:1 on --background in light and 4.06:1 on --card in dark — a known, documented shortfall) and splits off to --red-ink under data-contrast="high" #9A3537 in light, and the lighter dark-mode step of --red in dark, because an ink step has to move away from whichever background it sits on. Restyle the message with text-destructive and you pin it to one palette.

Reading field state

useFormField is exported so your own components can read the same ids and validation state the built-in parts use — character counters, inline hints, custom error affordances. Call it from anything rendered inside a FormField; the demo runs in mode: 'onChange', so type past 120 characters to watch it flip.

0 / 120 characters

Outside a FormField the hook throws immediately, before it touches the missing context — so the message you get names the real mistake instead of a downstream crash. It is a client hook: calling it from a Server Component throws Attempted to call useFormField() from the server.

PropTypeDefaultDescription
idstringThe raw useId() value minted by the surrounding FormItem.
namestringThe field name published by the surrounding FormField.
formItemIdstringid put on the control by FormControl and referenced by FormLabel.
formDescriptionIdstringid of the description node, always part of aria-describedby.
formMessageIdstringid of the error node, added to aria-describedby only while invalid.
errorFieldError | undefinedThe active validation error for this field, if any.
invalid / isDirty / isTouched / isValidatingbooleanSpread from react-hook-form's getFieldState — the rest of the field's live state.

Which FormField?

The package ships two components called FormField, and they solve different problems. The one on this page, from @comitor/ui/form, is the react-hook-form binding — a render-prop wrapper around Controller that draws no markup of its own. The other, from @comitor/ui, is a Tier 2 layout composite: label, control slot, description and error on a 12-column grid, with no form library involved.

Trap: the two names collide, so they can never appear in the same import statement — and an editor auto-import will happily pick the wrong one. If a FormField is complaining about a missing children, or about control not existing, check which entry it came from first.

tsx
// ── Controlled by react-hook-form ────────────────────────────────
// Render-prop wrapper around RHF's <Controller>. Needs control + name.
import { FormField, FormItem, FormLabel, FormControl } from '@comitor/ui/form'

<FormField
  control={form.control}
  name="email"
  render={({ field }) => (
    <FormItem>
      <FormLabel>Email</FormLabel>
      <FormControl><Input {...field} /></FormControl>
    </FormItem>
  )}
/>

// ── Layout only, no form library ─────────────────────────────────
// Tier 2 composite: label + control + description/error on a 12-column grid.
import { FormField, FormSection, Input } from '@comitor/ui'

<FormSection title="Contact">
  <FormField label="Email" required colSpan={6} error={errors.email}>
    {(control) => <Input {...control} value={email} onChange={onChange} />}
  </FormField>
</FormSection>

// Never import both in one file — same name, different components.

Props

PropTypeDefaultDescription
FormFormProps = ComponentProps<typeof FormProvider>Alias for react-hook-form's FormProvider — spread the object returned by useForm() onto it to wire the tree.
FormFieldFormFieldProps = ControllerProps<TFieldValues, TName>Wraps RHF's Controller and publishes the field name to the components below it. Takes every Controller prop — name, render, control, rules, defaultValue, disabled, shouldUnregister, exact.
FormField.namerequiredFieldPath<TFieldValues>The form field key registered with react-hook-form.
FormField.renderrequired({ field, fieldState, formState }) => ReactElementRender prop from Controller; spread field onto the input.
FormField.controlControl<TFieldValues>The control object from useForm(). Optional — omit it and Controller reads the nearest Form (FormProvider) from context.
FormField.rulesOmit<RegisterOptions<TFieldValues, TName>, 'valueAsNumber' | 'valueAsDate' | 'setValueAs' | 'disabled'>Per-field validation rules — required, pattern, min, max, validate. The four omitted keys belong to register(), not to a controlled field.
FormItemFormItemProps = ComponentProps<'div'>grid gap-2 wrapper that mints one useId() scope, the source of the id shared by label, control, description, and message.
FormLabelFormLabelProps = ComponentProps<typeof LabelPrimitive.Root>Label auto-linked to the control via htmlFor. Carries data-error and switches to text-destructive-ink when the field has an error.
FormControlFormControlProps = ComponentProps<typeof Slot>Slot that forwards id, aria-describedby, and aria-invalid onto its single input child. Accepts exactly one child.
FormDescriptionFormDescriptionProps = ComponentProps<'p'>Muted helper text referenced by the control via aria-describedby.
FormMessageFormMessageProps = ComponentProps<'p'>Renders the field's validation error, or children when there is no error. Returns null when both are empty.

Accessibility

  • FormLabel sets htmlFor to the generated control id, so clicking the label focuses the input and screen readers announce the pairing.
  • FormControl always points aria-describedby at the description, and appends the message id only while the field is invalid — so the error is announced when it appears rather than sitting silently in the accessible description.
  • When a field has an error the control receives aria-invalid="true" and the label gets data-error="true", conveying state beyond color alone.
  • Error text uses the -ink step of the destructive role rather than the background step, so it follows the high-contrast palette instead of staying pinned to the default one.
  • Every id is derived from React.useId() per FormItem, so labels and messages stay correctly associated even with many fields on the page.
  • FormControl is a Radix Slot: it merges its props onto exactly one child. Wrapping two elements silently drops the ARIA wiring.