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.
// 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.
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.
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.
| Prop | Type | Default | Description |
|---|---|---|---|
id | string | — | The raw useId() value minted by the surrounding FormItem. |
name | string | — | The field name published by the surrounding FormField. |
formItemId | string | — | id put on the control by FormControl and referenced by FormLabel. |
formDescriptionId | string | — | id of the description node, always part of aria-describedby. |
formMessageId | string | — | id of the error node, added to aria-describedby only while invalid. |
error | FieldError | undefined | — | The active validation error for this field, if any. |
invalid / isDirty / isTouched / isValidating | boolean | — | Spread 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.
// ── 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
| Prop | Type | Default | Description |
|---|---|---|---|
Form | FormProps = ComponentProps<typeof FormProvider> | — | Alias for react-hook-form's FormProvider — spread the object returned by useForm() onto it to wire the tree. |
FormField | FormFieldProps = 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.namerequired | FieldPath<TFieldValues> | — | The form field key registered with react-hook-form. |
FormField.renderrequired | ({ field, fieldState, formState }) => ReactElement | — | Render prop from Controller; spread field onto the input. |
FormField.control | Control<TFieldValues> | — | The control object from useForm(). Optional — omit it and Controller reads the nearest Form (FormProvider) from context. |
FormField.rules | Omit<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. |
FormItem | FormItemProps = ComponentProps<'div'> | — | grid gap-2 wrapper that mints one useId() scope, the source of the id shared by label, control, description, and message. |
FormLabel | FormLabelProps = 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. |
FormControl | FormControlProps = ComponentProps<typeof Slot> | — | Slot that forwards id, aria-describedby, and aria-invalid onto its single input child. Accepts exactly one child. |
FormDescription | FormDescriptionProps = ComponentProps<'p'> | — | Muted helper text referenced by the control via aria-describedby. |
FormMessage | FormMessageProps = ComponentProps<'p'> | — | Renders the field's validation error, or children when there is no error. Returns null when both are empty. |
Accessibility
FormLabelsetshtmlForto the generated control id, so clicking the label focuses the input and screen readers announce the pairing.FormControlalways pointsaria-describedbyat 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 getsdata-error="true", conveying state beyond color alone. - Error text uses the
-inkstep 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()perFormItem, so labels and messages stay correctly associated even with many fields on the page. FormControlis a RadixSlot: it merges its props onto exactly one child. Wrapping two elements silently drops the ARIA wiring.