Form
Validation wrapper for composing labels, descriptions, controls, and messages. Install Form from the VLLNT UI registry with the shadcn CLI.
What it is and 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.
FormValidation wrapper for composing labels, descriptions, controls, and messages. Part of the form family in VLLNT UI, it ships as a machine-readable registry entry — copy the source directly into your app with the shadcn CLI and own it, no runtime dependency on a component library.
Preview
Switch between light and dark to inspect the embedded Storybook preview.
Installation
Add Form to your project with the shadcn CLI. The source lands in your codebase, ready to adapt:
pnpm dlx shadcn@latest add https://ui.vllnt.com/r/form.jsonSource
"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,
};
Stories
Explore every variant and state in the interactive Storybook:
Preview
Switch between light and dark to inspect the embedded Storybook preview.
FAQ
- 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.