Form

Build accessible, validated React forms with labels, descriptions, and inline error messages. Powered by React Hook Form and Zod. Install via the shadcn CLI.

Report a bug

What it is & when to use

Form is a composable validation wrapper for React built on React Hook Form. It groups a label, control, description, and error message into accessible field units, automatically wiring aria-describedby, aria-invalid, and aria-required and marking error messages with role="alert". Pass a Zod schema for managed validation, or hand it your own useForm instance. Use it whenever you need labelled, validated inputs with consistent error handling across sign-up, settings, or checkout forms.

Browse all Form

Preview

Switch between light and dark to inspect the embedded Storybook preview.

Installation

pnpm dlx shadcn@latest add https://ui.vllnt.com/r/form.json

Storybook

Explore all variants, controls, and accessibility checks in the interactive Storybook playground.

View in Storybook

2 stories available:

Code

"use client"; import * as React from "react"; import { zodResolver } from "@hookform/resolvers/zod"; import { Slot } from "@radix-ui/react-slot"; import { Controller, type ControllerProps, type DefaultValues, type FieldPath, type FieldValues, FormProvider, type Resolver, type SubmitErrorHandler, useForm, useFormContext, type UseFormReturn, } from "react-hook-form"; import { cn } from "../../lib/utils"; import { Label } from "../label/label"; type FormInstance<TFieldValues extends FieldValues> = UseFormReturn< TFieldValues, unknown, TFieldValues >; type FormRenderChildren<TFieldValues extends FieldValues> = | ((form: FormInstance<TFieldValues>) => React.ReactNode) | React.ReactNode; type FormSubmitHandler<TFieldValues extends FieldValues> = ( values: TFieldValues, form: FormInstance<TFieldValues>, ) => Promise<void> | void; type FormErrorHandler<TFieldValues extends FieldValues> = ( errors: Parameters<SubmitErrorHandler<TFieldValues>>[0], form: FormInstance<TFieldValues>, ) => Promise<void> | void; type BaseFormProps<TFieldValues extends FieldValues> = Omit< React.ComponentPropsWithoutRef<"form">, "children" > & { children?: FormRenderChildren<TFieldValues>; controlId?: string; descriptionId?: string; disabled?: boolean; invalid?: boolean; messageId?: string; onError?: FormErrorHandler<TFieldValues>; onValidSubmit?: FormSubmitHandler<TFieldValues>; required?: boolean; }; type ManagedFormProps<TFieldValues extends FieldValues> = { defaultValues?: DefaultValues<TFieldValues>; form?: undefined; resolver?: Resolver<TFieldValues>; schema?: Parameters<typeof zodResolver>[0]; values?: TFieldValues; }; type ProvidedFormProps<TFieldValues extends FieldValues> = { defaultValues?: never; form: FormInstance<TFieldValues>; resolver?: never; schema?: never; values?: never; }; export type FormProps<TFieldValues extends FieldValues = FieldValues> = BaseFormProps<TFieldValues> & (ManagedFormProps<TFieldValues> | ProvidedFormProps<TFieldValues>); type FormNativeSubmitHandler = React.ComponentPropsWithoutRef<"form">["onSubmit"]; type FormRootContextValue = { controlId?: string; descriptionId?: string; disabled: boolean; invalid: boolean; messageId?: string; required: boolean; }; type FormFieldContextValue = { name: string; }; type FormItemContextValue = { controlId: string; descriptionId: string; disabled: boolean; hasDescription: boolean; hasMessage: boolean; hasMessageSlot: boolean; id: string; invalid: boolean; messageId: string; required: boolean; }; const FormRootContext = React.createContext<FormRootContextValue | undefined>( undefined, ); const FormFieldContext = React.createContext<FormFieldContextValue | undefined>( undefined, ); const FormItemContext = React.createContext<FormItemContextValue | undefined>( undefined, ); function useFormRootContext(componentName: string) { const context = React.use(FormRootContext); if (context === undefined) { throw new Error(`${componentName} must be used within Form.`); } return context; } function useFormItemContext(componentName: string) { const context = React.use(FormItemContext); if (context === undefined) { throw new Error(`${componentName} must be used within FormItem.`); } return context; } function composeIds(...ids: (string | undefined)[]) { const value = ids.filter((id) => id !== undefined && id.length > 0).join(" "); return value.length > 0 ? value : undefined; } function resolveItemId( baseId: string | undefined, generatedId: string, suffix: string, ) { if (baseId === undefined) { return `${generatedId}-${suffix}`; } return baseId.endsWith(`-${suffix}`) ? `${baseId}-${generatedId}` : `${baseId}-${suffix}-${generatedId}`; } function isNamedFormChild( child: React.ReactNode, name: "FormDescription" | "FormMessage", ): child is React.ReactElement<{ children?: React.ReactNode }> { if (!React.isValidElement<{ children?: React.ReactNode }>(child)) { return false; } const { type } = child; if (typeof type === "string" || typeof type === "symbol") { return false; } return "displayName" in type && type.displayName === name; } function hasVisibleContent(children: React.ReactNode): boolean { return React.Children.toArray(children).some((child) => { if (child === null || child === undefined || typeof child === "boolean") { return false; } if (typeof child === "string") { return child.length > 0; } if (typeof child === "number") { return true; } if (React.isValidElement<{ children?: React.ReactNode }>(child)) { const nestedChildren = child.props.children; return nestedChildren === undefined ? true : hasVisibleContent(nestedChildren); } return true; }); } function hasFormChild( children: React.ReactNode, name: "FormDescription" | "FormMessage", ): boolean { return React.Children.toArray(children).some((child) => { if (isNamedFormChild(child, name)) { return true; } if (React.isValidElement<{ children?: React.ReactNode }>(child)) { return hasFormChild(child.props.children, name); } return false; }); } function hasRenderedFormChild( children: React.ReactNode, name: "FormDescription" | "FormMessage", ): boolean { return React.Children.toArray(children).some((child) => { if (isNamedFormChild(child, name)) { return name === "FormMessage" ? hasVisibleContent(child.props.children) : true; } if (React.isValidElement<{ children?: React.ReactNode }>(child)) { return hasRenderedFormChild(child.props.children, name); } return false; }); } function createManagedSubmitHandler<TFieldValues extends FieldValues>( form: FormInstance<TFieldValues>, options: { onError?: FormErrorHandler<TFieldValues>; onValidSubmit?: FormSubmitHandler<TFieldValues>; shouldValidate: boolean; }, ): ReturnType<FormInstance<TFieldValues>["handleSubmit"]> | undefined { const { onError, onValidSubmit, shouldValidate } = options; if (!shouldValidate) { return undefined; } return form.handleSubmit( async (submittedValues) => { if (onValidSubmit !== undefined) { await onValidSubmit(submittedValues, form); } }, async (errors) => { if (onError !== undefined) { await onError(errors, form); } }, ); } function createSubmitHandler( nativeSubmit: FormNativeSubmitHandler, handleValidatedSubmit: | ((event?: React.BaseSyntheticEvent) => Promise<void>) | undefined, ): FormNativeSubmitHandler { return async (event) => { nativeSubmit?.(event); if (handleValidatedSubmit && !event.defaultPrevented) { await handleValidatedSubmit(event); } }; } function useFormRootContextValue( value: FormRootContextValue, ): FormRootContextValue { const { controlId, descriptionId, disabled, invalid, messageId, required } = value; return React.useMemo( () => ({ controlId, descriptionId, disabled, invalid, messageId, required, }), [controlId, descriptionId, disabled, invalid, messageId, required], ); } type FormMarkupProps<TFieldValues extends FieldValues> = { children: FormProps<TFieldValues>["children"]; className?: string; disabled: boolean; form: FormInstance<TFieldValues>; formProps: Omit< React.ComponentPropsWithoutRef<"form">, "children" | "className" | "onSubmit" >; formRef?: React.Ref<HTMLFormElement>; handleValidatedSubmit?: ReturnType< FormInstance<TFieldValues>["handleSubmit"] >; invalid: boolean; onSubmit: FormNativeSubmitHandler; rootContextValue: FormRootContextValue; }; function FormMarkup<TFieldValues extends FieldValues>({ children, className, disabled, form, formProps, formRef, handleValidatedSubmit, invalid, onSubmit, rootContextValue, }: FormMarkupProps<TFieldValues>) { return ( <FormRootContext.Provider value={rootContextValue}> <FormProvider {...form}> <form className={cn("space-y-2", className)} data-disabled={ disabled || form.formState.isSubmitting ? "true" : undefined } data-invalid={invalid ? "true" : undefined} data-submitting={form.formState.isSubmitting ? "true" : undefined} onSubmit={ onSubmit === undefined && handleValidatedSubmit === undefined ? undefined : createSubmitHandler(onSubmit, handleValidatedSubmit) } ref={formRef} {...formProps} > {typeof children === "function" ? children(form) : children} </form> </FormProvider> </FormRootContext.Provider> ); } function FormInner<TFieldValues extends FieldValues = FieldValues>({ children, className, controlId, defaultValues, descriptionId, disabled = false, form: providedForm, invalid = false, messageId, onError, onSubmit, onValidSubmit, ref, required = false, resolver, schema, values, ...props }: FormProps<TFieldValues> & { ref?: React.Ref<HTMLFormElement> }) { const internalForm = useForm<TFieldValues>({ defaultValues, resolver: schema === undefined ? resolver : (zodResolver(schema) as Resolver<TFieldValues>), values, }); const form: FormInstance<TFieldValues> = providedForm ?? internalForm; const isManagedForm = providedForm !== undefined || resolver !== undefined || schema !== undefined; const rootContextValue = useFormRootContextValue({ controlId, descriptionId, disabled, invalid, messageId, required, }); const handleValidatedSubmit = createManagedSubmitHandler(form, { onError, onValidSubmit, shouldValidate: isManagedForm || onValidSubmit !== undefined || onError !== undefined, }); return ( <FormMarkup className={className} disabled={disabled} form={form} formProps={props} formRef={ref} handleValidatedSubmit={handleValidatedSubmit} invalid={invalid} onSubmit={onSubmit} rootContextValue={rootContextValue} > {children} </FormMarkup> ); } FormInner.displayName = "Form"; const Form = FormInner; function FormField< TFieldValues extends FieldValues = FieldValues, TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>, >({ ...props }: ControllerProps<TFieldValues, TName>) { const fieldContextValue = React.useMemo( () => ({ name: props.name }), [props.name], ); return ( <FormFieldContext.Provider value={fieldContextValue}> <Controller {...props} /> </FormFieldContext.Provider> ); } function useFormField() { const fieldContext = React.use(FormFieldContext); const itemContext = useFormItemContext("useFormField"); const { formState, getFieldState } = useFormContext(); if (fieldContext === undefined) { return { disabled: itemContext.disabled, error: undefined, formDescriptionId: itemContext.descriptionId, formItemId: itemContext.controlId, formMessageId: itemContext.messageId, hasDescription: itemContext.hasDescription, hasMessage: itemContext.hasMessage, hasMessageSlot: itemContext.hasMessageSlot, id: itemContext.id, invalid: itemContext.invalid, isDirty: false, isTouched: false, isValidating: false, name: "", required: itemContext.required, }; } const fieldState = getFieldState(fieldContext.name, formState); return { disabled: itemContext.disabled, error: fieldState.error, formDescriptionId: itemContext.descriptionId, formItemId: itemContext.controlId, formMessageId: itemContext.messageId, hasDescription: itemContext.hasDescription, hasMessage: itemContext.hasMessage, hasMessageSlot: itemContext.hasMessageSlot, id: itemContext.id, invalid: itemContext.invalid || fieldState.invalid, isDirty: fieldState.isDirty, isTouched: fieldState.isTouched, isValidating: fieldState.isValidating, name: fieldContext.name, required: itemContext.required, }; } const FormItem = ({ children, className, disabled: itemDisabled, invalid: itemInvalid, ref, required: itemRequired, ...props }: React.ComponentPropsWithoutRef<"div"> & { disabled?: boolean; invalid?: boolean; ref?: React.Ref<HTMLDivElement>; required?: boolean; }) => { const { controlId: controlIdBase, descriptionId: descriptionIdBase, disabled, invalid, messageId: messageIdBase, required, } = useFormRootContext("FormItem"); const generatedId = React.useId(); const hasDescription = hasRenderedFormChild(children, "FormDescription"); const hasMessage = hasRenderedFormChild(children, "FormMessage"); const hasMessageSlot = hasFormChild(children, "FormMessage"); const effectiveDisabled = itemDisabled ?? disabled; const effectiveInvalid = itemInvalid ?? invalid; const effectiveRequired = itemRequired ?? required; const value = React.useMemo<FormItemContextValue>( () => ({ controlId: resolveItemId(controlIdBase, generatedId, "control"), descriptionId: resolveItemId( descriptionIdBase, generatedId, "description", ), disabled: effectiveDisabled, hasDescription, hasMessage, hasMessageSlot, id: generatedId, invalid: effectiveInvalid, messageId: resolveItemId(messageIdBase, generatedId, "message"), required: effectiveRequired, }), [ controlIdBase, descriptionIdBase, effectiveDisabled, effectiveInvalid, effectiveRequired, generatedId, hasDescription, hasMessage, hasMessageSlot, messageIdBase, ], ); return ( <FormItemContext.Provider value={value}> <div className={cn("space-y-2", className)} ref={ref} {...props}> {children} </div> </FormItemContext.Provider> ); }; FormItem.displayName = "FormItem"; const FormLabel = ({ className, htmlFor, ref, ...props }: React.ComponentPropsWithoutRef<typeof Label> & { ref?: React.Ref<React.ComponentRef<typeof Label>>; }) => { const { formItemId, invalid } = useFormField(); return ( <Label className={cn(invalid && "text-destructive", className)} data-invalid={invalid ? "true" : undefined} htmlFor={htmlFor ?? formItemId} ref={ref} {...props} /> ); }; FormLabel.displayName = "FormLabel"; type FormControlProps = React.ComponentPropsWithoutRef<typeof Slot> & { disabled?: boolean; required?: boolean; }; const FormControl = ({ disabled: controlDisabled, id: _id, ref, required: controlRequired, ...props }: FormControlProps & { ref?: React.Ref<HTMLElement> }) => { const { disabled, error, formDescriptionId, formItemId, formMessageId, hasDescription, hasMessage, hasMessageSlot, invalid, required, } = useFormField(); const { formState } = useFormContext(); const hasErrorMessage = hasVisibleContent(error?.message); const describedBy = composeIds( props["aria-describedby"], hasDescription ? formDescriptionId : undefined, error === undefined ? hasMessage && invalid ? formMessageId : undefined : hasMessageSlot && hasErrorMessage ? formMessageId : undefined, ); const effectiveDisabled = controlDisabled ?? (disabled || formState.isSubmitting); const effectiveRequired = controlRequired ?? required; const nativeConstraintProps: { disabled?: boolean; required?: boolean; } = { disabled: effectiveDisabled || undefined, required: effectiveRequired || undefined, }; return ( <Slot {...props} {...nativeConstraintProps} aria-describedby={describedBy} aria-disabled={props["aria-disabled"] ?? (effectiveDisabled || undefined)} aria-invalid={props["aria-invalid"] ?? (invalid || undefined)} aria-required={props["aria-required"] ?? (effectiveRequired || undefined)} data-disabled={effectiveDisabled ? "true" : undefined} data-invalid={invalid ? "true" : undefined} id={formItemId} ref={ref} /> ); }; FormControl.displayName = "FormControl"; const FormDescription = ({ className, id: _id, ref, ...props }: React.ComponentPropsWithoutRef<"p"> & { ref?: React.Ref<HTMLParagraphElement>; }) => { const { formDescriptionId } = useFormField(); return ( <p {...props} className={cn("text-sm text-muted-foreground", className)} id={formDescriptionId} ref={ref} /> ); }; FormDescription.displayName = "FormDescription"; const FormMessage = ({ children, className, id: _id, ref, ...props }: React.ComponentPropsWithoutRef<"p"> & { ref?: React.Ref<HTMLParagraphElement>; }) => { const { error, formMessageId, invalid } = useFormField(); const body = error?.message ?? children; if (!hasVisibleContent(body)) { return null; } return ( <p {...props} className={cn( "text-sm font-medium", invalid || error !== undefined ? "text-destructive" : "text-foreground", className, )} id={formMessageId} ref={ref} role={invalid || error !== undefined ? "alert" : undefined} > {body} </p> ); }; FormMessage.displayName = "FormMessage"; export { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, useFormField, };

Dependencies

  • @vllnt/ui@^0.3.0

Frequently asked questions

What is Form?
Form is an accessible, composable form wrapper for React. It pairs Label, control, description, and message parts with React Hook Form so each field validates and reports errors with the correct ARIA wiring.
How do I add Form?
Run `pnpm dlx shadcn@latest add https://ui.vllnt.com/r/form.json` to install it into your project via the shadcn CLI.
Does Form handle validation and accessibility?
Yes. It integrates React Hook Form and Zod through zodResolver for schema validation, and automatically wires aria-describedby, aria-invalid, aria-required, and role="alert" on error messages.