Composition recipe
Build accessible, validated forms with React Hook Form, Zod, and the Field family.
npx shadcn@4.19.0 add https://ui.edbn.me/r/field.json https://ui.edbn.me/r/input.json https://ui.edbn.me/r/input-group.json https://ui.edbn.me/r/textarea.json https://ui.edbn.me/r/button.json https://ui.edbn.me/r/select.json https://ui.edbn.me/r/checkbox.json https://ui.edbn.me/r/radio-group.json https://ui.edbn.me/r/switch.jsonnpm install react-hook-form @hookform/resolvers zodReact Hook Form owns field state and submission. Zod owns the schema, while the Field family owns labels, descriptions, invalid styling, and announced error messages. The recipe remains application code so its data model does not leak into a generic wrapper.
data-invalid on Field, pass aria-invalid to the interactive control, and render the controller error through FieldError.const schema = z.object({
title: z.string().min(5, "Use at least 5 characters."),
})
const form = useForm<z.infer<typeof schema>>({
resolver: zodResolver(schema),
defaultValues: { title: "" },
})Supply every controlled field with a default value. This keeps the first client render aligned with the server and makes reset behavior predictable.
<Controller
name="title"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor={field.name}>Title</FieldLabel>
<Input {...field} id={field.name} aria-invalid={fieldState.invalid} />
<FieldError errors={[fieldState.error]} />
</Field>
)}
/>Use the same bridge for Base UI Select, Checkbox, Radio Group, and Switch. Translate their value callbacks to field.onChange instead of registering hidden inputs.
useFieldArray.The component building blocks are documented under Field, Input, and Select.