Use these helpers with React Hook Form to connect fields to validation and accessible descriptions. Form is a provider, not a native form element. Render a form and attach its submit handler.
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
const formSchema = z.object({
username: z.string().min(2, {
message: "Username must be at least 2 characters.",
}),
});
export default function FormDemo() {
const [message, setMessage] = useState("");
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
username: "",
},
});
function onSubmit(values: z.infer<typeof formSchema>) {
setMessage(`${values.username} was updated in this demo. Nothing was saved to a server.`);
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="johndoe" {...field} />
</FormControl>
<FormDescription>This is your public display name.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Submit</Button>
<output className="block min-h-5 text-sm" aria-live="polite">
{message}
</output>
</form>
</Form>
);
}
#Installation
Complete the base setup. For manual installation:
First time? Install the shared base once.
npx shadcn@latest add https://neobrutal-ui.andongmin.com/r/form.jsonnpm install @base-ui/react@^1.8.0 @hookform/resolvers@^5.4.0 react-hook-form@^7.81.0 zod@^4.4.3Also install Label, then create components/ui/form.tsx:
"use client";
import {
Controller,
FormProvider,
useFormContext,
useFormState,
type ControllerProps,
type FieldPath,
type FieldValues,
} from "react-hook-form";
import { useRender } from "@base-ui/react/use-render";
import * as React from "react";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
const Form = FormProvider;
const FormFieldContext = React.createContext<{ name: string } | null>(null);
type Association = "control" | "description" | "message";
type FormItemContextValue = {
id: string;
ids: Partial<Record<Association, string>>;
register: (part: Association, id: string) => () => void;
};
const FormItemContext = React.createContext<FormItemContextValue | null>(null);
function FormField<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({ ...props }: ControllerProps<TFieldValues, TName>) {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
);
}
function useFormField() {
const field = React.useContext(FormFieldContext);
const item = React.useContext(FormItemContext);
const form = useFormContext();
if (!form) throw new Error("useFormField should be used within <Form>");
if (!field) throw new Error("useFormField should be used within <FormField>");
if (!item) throw new Error("useFormField should be used within <FormItem>");
const state = useFormState({ control: form.control, name: field.name, exact: true });
return {
...item,
name: field.name,
formItemId: item.ids.control ?? `${item.id}-form-item`,
formDescriptionId: `${item.id}-form-item-description`,
formMessageId: `${item.id}-form-item-message`,
...form.getFieldState(field.name, state),
};
}
function FormItem({ id: providedId, className, ...props }: React.ComponentProps<"div">) {
const generatedId = React.useId();
const id = providedId ?? generatedId;
const [ids, setIds] = React.useState<FormItemContextValue["ids"]>({});
const register = React.useCallback((part: Association, targetId: string) => {
setIds((current) => (current[part] === targetId ? current : { ...current, [part]: targetId }));
return () => {
setIds((current) =>
current[part] === targetId ? { ...current, [part]: undefined } : current,
);
};
}, []);
return (
<FormItemContext.Provider value={{ id, ids, register }}>
<div
id={providedId}
data-slot="form-item"
className={cn("grid gap-2", className)}
{...props}
/>
</FormItemContext.Provider>
);
}
function useAssociation(
register: FormItemContextValue["register"],
part: Association,
id: string,
present = true,
) {
// Register only rendered parts. Never point at a description or error that is absent.
React.useLayoutEffect(() => {
if (present) return register(part, id);
}, [register, part, id, present]);
}
function FormLabel({ className, ...props }: React.ComponentProps<typeof Label>) {
const { error, formItemId } = useFormField();
return (
<Label
data-slot="form-label"
data-error={!!error}
className={cn("font-heading", className)}
htmlFor={formItemId}
{...props}
/>
);
}
type FormControlProps = React.HTMLAttributes<HTMLElement> & {
children: React.ReactElement;
};
const FormControl = React.forwardRef<HTMLElement, FormControlProps>(function FormControl(
{ children, ...props },
forwardedRef,
) {
const { error, id, ids, register } = useFormField();
if (!React.isValidElement(children) || children.type === React.Fragment) {
throw new Error("FormControl requires exactly one non-Fragment React element.");
}
const childProps = children.props as React.HTMLAttributes<HTMLElement>;
const controlId = childProps.id ?? props.id ?? `${id}-form-item`;
useAssociation(register, "control", controlId);
const describedBy =
[
...new Set(
[props["aria-describedby"], childProps["aria-describedby"], ids.description, ids.message]
.filter(Boolean)
.flatMap((value) => value!.split(/\s+/).filter(Boolean)),
),
].join(" ") || undefined;
const associations = {
id: controlId,
"aria-describedby": describedBy,
"aria-invalid": error ? true : (childProps["aria-invalid"] ?? props["aria-invalid"] ?? false),
};
return useRender({
defaultTagName: "span",
props: { "data-slot": "form-control", ...props, ...associations },
ref: forwardedRef,
// useRender composes the child's ref and event handlers; only IDREFs need merging here.
render: React.cloneElement(
children as React.ReactElement<React.HTMLAttributes<HTMLElement>>,
associations,
),
state: {},
});
});
function FormDescription({ id, className, ...props }: React.ComponentProps<"p">) {
const { formDescriptionId, register } = useFormField();
const descriptionId = id ?? formDescriptionId;
useAssociation(register, "description", descriptionId);
return (
<p
data-slot="form-description"
id={descriptionId}
className={cn("text-sm font-base text-foreground", className)}
{...props}
/>
);
}
function FormMessage({ id, className, children, ...props }: React.ComponentProps<"p">) {
const { error, formMessageId, register } = useFormField();
const messageId = id ?? formMessageId;
const body = error ? String(error.message ?? "") : children;
const present = body !== undefined && body !== null && body !== false && body !== "";
useAssociation(register, "message", messageId, present);
if (!present) return null;
return (
<p
data-slot="form-message"
id={messageId}
className={cn("text-sm font-base text-error", className)}
{...props}
>
{body}
</p>
);
}
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
};
The complete example additionally uses Input and Button. Install them before copying the Code or Usage example. This minimal example uses built-in field rules; the preview demonstrates schema validation.
npx shadcn@latest add https://neobrutal-ui.andongmin.com/r/input.json https://neobrutal-ui.andongmin.com/r/button.json#Usage
"use client";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Form,
FormField,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
} from "@/components/ui/form";
type Values = { username: string };
export default function ProfileForm() {
const form = useForm<Values>({ defaultValues: { username: "" } });
const [message, setMessage] = useState("");
return (
<Form {...form}>
<form
className="grid max-w-sm gap-4"
onSubmit={form.handleSubmit((values) => setMessage(`Local preview: ${values.username}`))}
>
<FormField
control={form.control}
name="username"
rules={{ required: "Enter a display name." }}
render={({ field }) => (
<FormItem>
<FormLabel>Display name</FormLabel>
<FormControl>
<Input autoComplete="nickname" {...field} />
</FormControl>
<FormDescription>Visible to workspace members.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Save preview</Button>
<p role="status">{message}</p>
</form>
</Form>
);
}#Optional help and custom controls
FormDescription and FormMessage are optional. Render at most one of each per FormItem.
Their references are registered when mounted and removed when hidden or unmounted, including when an error clears.
Existing aria-describedby references on the control and on FormControl are combined without duplicates.
Application-owned reference targets must exist in the document.
An explicit control ID is preserved and its label follows it after hydration. For a label that must also target a custom control before hydration, pass the same explicit ID as FormLabel.htmlFor.
Custom controls must forward their HTML props and ref to the actual focusable element.
The wrapper composes child and wrapper event handlers and refs using Base UI, not a second input.
"use client";
import { useId, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
export default function FormComposition() {
const id = useId();
const form = useForm({ defaultValues: { first: "", second: "" }, mode: "onChange" });
const controlRef = useRef<HTMLElement>(null);
const [showHelp, setShowHelp] = useState(false);
const [blurCount, setBlurCount] = useState(0);
const [message, setMessage] = useState("");
return (
<Form {...form}>
<form
aria-label="Optional field composition"
className="grid w-full max-w-md gap-5"
onSubmit={form.handleSubmit(() => setMessage("Both fields are valid. Nothing was saved."))}
>
<FormField
control={form.control}
name="first"
rules={{ required: "Enter the first value." }}
render={({ field }) => (
<FormItem>
<FormLabel>First value</FormLabel>
<FormControl ref={controlRef} onBlur={() => setBlurCount((count) => count + 1)}>
<Input {...field} id={`${id}-custom-input`} aria-describedby={`${id}-external`} />
</FormControl>
<p id={`${id}-external`} className="text-sm">
This help belongs to the application.
</p>
{showHelp && (
<FormDescription id={`${id}-custom-help`}>Optional field help.</FormDescription>
)}
<FormMessage id={`${id}-custom-error`} />
</FormItem>
)}
/>
<FormField
control={form.control}
name="second"
rules={{ required: "Enter the second value." }}
render={({ field }) => (
<FormItem>
<FormLabel>Second value</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex flex-wrap gap-3">
<Button type="button" variant="neutral" onClick={() => setShowHelp((value) => !value)}>
{showHelp ? "Hide help" : "Show help"}
</Button>
<Button type="button" variant="neutral" onClick={() => controlRef.current?.focus()}>
Focus first field
</Button>
<Button type="submit">Validate fields</Button>
</div>
<p className="text-sm">First field blur events: {blurCount}</p>
<p className="text-sm">
First field touched: {form.formState.touchedFields.first ? "yes" : "no"}
</p>
<output className="text-sm">{message}</output>
</form>
</Form>
);
}
#API reference
| Part | Purpose and usage |
|---|---|
Form | Provides React Hook Form methods. Spread the result of useForm onto it. |
FormField | Binds a typed field using control, name, and render. |
FormItem | Wraps one field and shares its label, control, and mounted message IDs. |
FormLabel | Labels the control. Place it inside FormItem. |
FormControl | Passes the field ID, mounted help references, and invalid state to one control that forwards props and ref. |
FormDescription | Optional help text linked to the control after mounting; supports a custom ID. |
FormMessage | Optional validation feedback linked while rendered; supports a custom ID. |
Form helpers require Form, FormField, and FormItem in the field tree. A missing provider
produces an explicit diagnostic. FormControl does not accept an empty child, text, a Fragment,
or multiple children. Error text uses the shared --error token, independently of filled
destructive-action colors.
#Accessibility
Keep labels visible and errors associated with the field. FormControl must wrap the actual control, not an unrelated layout container. Use proper input types and autocomplete attributes. Validate on the server as well as the client. During asynchronous submission show pending state, prevent accidental repeats, and keep error feedback available.