Wraps react-hook-form so every field gets a consistent id/label/error pattern — used across 40+ forms on the source platform. Requires react-hook-form as a peer dependency.
npx chandra-asri-ui add formSubmit with an empty email to see the error state below.
"use client";
import { Button } from "@/app/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/app/components/ui/form";
import { Input } from "@/app/components/ui/input";
import { useForm } from "react-hook-form";
type FormValues = { email: string };
/**
* The standard pattern used across 40+ forms on the source platform:
* Form + FormField + FormItem + FormLabel + FormControl + FormMessage
* wired to react-hook-form. Input's own border never changes color on
* error — FormLabel and FormMessage are the only visual error indicators.
*/
export function FormDemo() {
const form = useForm<FormValues>({ defaultValues: { email: "" } });
function onSubmit(values: FormValues) {
if (!values.email) {
form.setError("email", { message: "Email is required." });
}
}
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="w-full max-w-sm space-y-4"
>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" placeholder="you@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Submit</Button>
</form>
</Form>
);
}
Input and Textarea have no error styling of their own (see their docs pages) — this is the only place error state shows up visually: FormLabel turns text-danger-70, FormMessagerenders the error text in the same color. The field's own border never changes.
"use client";
import { Label } from "@/app/components/ui/label";
import { cn } from "@/lib/utils";
import { Slot } from "@radix-ui/react-slot";
import * as React from "react";
import {
Controller,
FormProvider,
useFormContext,
useFormState,
type ControllerProps,
type FieldPath,
type FieldValues,
} from "react-hook-form";
/**
* Wraps react-hook-form so every field gets a consistent id/label/error
* pattern — this is the only source of visual error state across forms in
* the source bundle: Input/Textarea never change border color on error,
* only FormLabel (turns red) and FormMessage (renders the error text) do.
* Uses this project's own danger scale (`text-danger-70`) instead of the
* source's theme-token `text-destructive`, matching how the rest of this
* bundle sources color from the brand scale.
*/
export const Form = FormProvider;
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName;
};
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue,
);
export 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>
);
}
type FormItemContextValue = {
id: string;
};
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue,
);
export function 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,
};
}
export const FormItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const id = React.useId();
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2", className)} {...props} />
</FormItemContext.Provider>
);
});
FormItem.displayName = "FormItem";
export const FormLabel = React.forwardRef<
React.ComponentRef<typeof Label>,
React.ComponentPropsWithoutRef<typeof Label>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField();
return (
<Label
ref={ref}
className={cn(error && "text-danger-70", className)}
htmlFor={formItemId}
{...props}
/>
);
});
FormLabel.displayName = "FormLabel";
export const FormControl = React.forwardRef<
HTMLElement,
React.HTMLAttributes<HTMLElement>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } =
useFormField();
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={
!error ? formDescriptionId : `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
);
});
FormControl.displayName = "FormControl";
export const FormDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField();
return (
<p
ref={ref}
id={formDescriptionId}
className={cn("text-muted text-[0.8rem]", className)}
{...props}
/>
);
});
FormDescription.displayName = "FormDescription";
export const FormMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField();
const body = error ? String(error?.message ?? "") : children;
if (!body) return null;
return (
<p
ref={ref}
id={formMessageId}
className={cn("text-danger-70 text-[0.8rem] font-medium", className)}
{...props}
>
{body}
</p>
);
});
FormMessage.displayName = "FormMessage";