react-hook-form bindings (`Form`, `FormField`, `FormItem`, `FormMessage`) with accessible labels and errors.
Installation
pnpm dlx shadcn@latest add https://rdf.dev.360online.com/r/form.jsonInstalls to @/components/ui/form.
Manual installation
Install the dependencies:
pnpm add @base-ui/react@^1.8.0 react-hook-form@^7.62.0Add the components it builds on: label.
Copy the source into your project:
'use client'
import { mergeProps } from '@base-ui/react/merge-props'
import { useRender } from '@base-ui/react/use-render'
import * as React from 'react'
import {
Controller,
type ControllerProps,
type FieldPath,
type FieldValues,
FormProvider,
useFormContext,
useFormState,
} from 'react-hook-form'
import { Label } from '@/components/ui/label'
import { cn } from '@/lib/utils'
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>({} as FormFieldContextValue)
const 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>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState } = useFormContext()
const formState = useFormState({ name: fieldContext.name })
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error('useFormField should be used within <FormField>')
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>({} as FormItemContextValue)
function FormItem({ className, ...props }: React.ComponentProps<'div'>) {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div data-slot="form-item" className={cn('grid gap-2', className)} {...props} />
</FormItemContext.Provider>
)
}
function FormLabel({ className, ...props }: React.ComponentProps<typeof Label>) {
const { error, formItemId } = useFormField()
return (
<Label
data-slot="form-label"
data-error={!!error}
className={cn('data-[error=true]:text-destructive break-words', className)}
htmlFor={formItemId}
{...props}
/>
)
}
type FormControlProps = React.HTMLAttributes<HTMLElement> & {
ref?: React.Ref<HTMLElement>
}
// Passes the field's id, aria-describedby and aria-invalid on to its only child (the form control)
function FormControl({ children, ref, ...props }: FormControlProps) {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
const child = React.isValidElement(children) ? children : undefined
return useRender({
defaultTagName: 'div',
render: child,
ref,
props: mergeProps<'div'>(
{
'data-slot': 'form-control',
id: formItemId,
'aria-describedby': error ? `${formDescriptionId} ${formMessageId}` : `${formDescriptionId}`,
'aria-invalid': !!error,
children: child ? undefined : children,
} as React.ComponentProps<'div'>,
props as React.ComponentProps<'div'>,
),
})
}
function FormDescription({ className, ...props }: React.ComponentProps<'p'>) {
const { formDescriptionId } = useFormField()
return (
<p
data-slot="form-description"
id={formDescriptionId}
className={cn('text-muted-foreground text-sm break-words', className)}
{...props}
/>
)
}
function FormMessage({ className, ...props }: React.ComponentProps<'p'>) {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message ?? '') : props.children
if (!body) {
return null
}
return (
<p data-slot="form-message" id={formMessageId} className={cn('text-destructive text-sm', className)} {...props}>
{body}
</p>
)
}
export { useFormField, Form, FormItem, FormLabel, FormControl, FormDescription, FormMessage, FormField }A form component built on top of React Hook Form with validation using Zod. The form components provide a flexible and accessible way to build forms with validation, error messages, and complex form state management.
Features
- Built on React Hook Form for powerful form state management
- Zod schema validation integration
- Accessible form controls with ARIA attributes
- Error message handling
- Form field descriptions
- Flexible form layouts
- TypeScript support
- Keyboard navigation
- Screen reader support
Usage
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import * as z from 'zod'
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
// Define your form schema
const formSchema = z.object({
caseNumber: z.string().regex(/^\d{4}-\d{5}$/, {
message: 'Case numbers look like 2026-04812.',
}),
})
export function FindCaseForm() {
// Initialize form with schema
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
caseNumber: '',
},
})
// Form submission handler
function onSubmit(values: z.infer<typeof formSchema>) {
console.log(values)
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<FormField
control={form.control}
name="caseNumber"
render={({ field }) => (
<FormItem>
<FormLabel>Case number</FormLabel>
<FormControl>
<Input placeholder="2026-04812" {...field} />
</FormControl>
<FormDescription>
You find it in the confirmation email.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Find case</Button>
</form>
</Form>
)
}Examples
School placement
A card-based e-service form with a text input, a select, a date, a switch and a required confirmation checkbox. Submit it empty to see the Zod messages appear under each field.
Public records request
Radio options are composed from nested FormItems so each option gets its own label and hint, while the group shares
one FormMessage.
Validation errors
The form is validated on mount (form.trigger()), showing how FormLabel, FormControl and FormMessage pick up the
error state and wire aria-invalid and aria-describedby for you.
Form Components
Form
The root form component that provides form context.
Props:
- All props from
react-hook-form'sFormProvider
FormField
A component that provides form field context and handles field registration.
Props:
control- Form control from React Hook Formname- Field namerender- Render function for the field
FormItem
Container for form field components.
Props:
className- Additional CSS classes- Standard HTML div props
FormLabel
Label component for form fields.
Props:
- Standard HTML label props
FormControl
Wraps a single form control and passes the field's id, aria-describedby and aria-invalid on to it (merged with
Base UI's useRender, so the control renders without an extra element).
Props:
- Standard HTML attributes, merged onto the child
- Handles aria-describedby and aria-invalid automatically
FormDescription
Descriptive text for form fields.
Props:
className- Additional CSS classes- Standard HTML paragraph props
FormMessage
Component for displaying validation errors.
Props:
className- Additional CSS classes- Standard HTML paragraph props
Accessibility
The Form components follow WAI-ARIA guidelines:
- Uses semantic HTML elements
- Proper label associations with form controls
- Error messages are announced to screen readers
- Proper ARIA attributes for validation states
- Keyboard navigation support
- Focus management
- Clear error indication
Best Practices
-
Form Structure:
- Group related fields
- Use clear labels
- Provide helpful descriptions
- Show validation errors inline
-
Validation:
- Use Zod schemas for validation
- Provide clear error messages
- Validate in real-time when appropriate
- Handle all possible states
-
User Experience:
- Clear submission feedback
- Disable submit while processing
- Preserve form state when appropriate
- Handle errors gracefully
-
Accessibility:
- Maintain proper heading hierarchy
- Use semantic HTML
- Test with screen readers
- Ensure keyboard navigation
Related Components
- Input
- Select
- Checkbox
- RadioGroup
- Switch
- Textarea