Forms

Form.

Form building blocks wired to react-hook-form, with zod validation, accessible labels, descriptions and error messages.

Shown in the hub directory.

hub://

Installation#

With the CLI

$npx lanterncn add form

This also installs the Lantern theme and any Lantern UI components it depends on. The shadcn CLI works directly too: npx shadcn@latest add httptim/lantern-ui/form. See Installation if your project is not set up yet.

Manually

  1. Install the dependencies

    npm install react-hook-form zod @hookform/resolvers radix-ui
  2. Copy the source into your project

    components/ui/form.tsx
    "use client";
    
    import * as React from "react";
    import type { Label as LabelPrimitive } from "radix-ui";
    import { Slot } from "radix-ui";
    import {
      Controller,
      FormProvider,
      useFormContext,
      useFormState,
      type ControllerProps,
      type FieldPath,
      type FieldValues,
    } from "react-hook-form";
    
    import { cn } from "@/lib/utils";
    import { Label } from "@/components/ui/label";
    
    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.5", className)} {...props} />
        </FormItemContext.Provider>
      );
    }
    
    function FormLabel({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
      const { error, formItemId } = useFormField();
    
      return (
        <Label
          data-slot="form-label"
          data-error={!!error}
          className={cn("data-[error=true]:text-destructive", className)}
          htmlFor={formItemId}
          {...props}
        />
      );
    }
    
    function FormControl({ ...props }: React.ComponentProps<typeof Slot.Root>) {
      const { error, formItemId, formDescriptionId, formMessageId } = useFormField();
    
      return (
        <Slot.Root
          data-slot="form-control"
          id={formItemId}
          aria-describedby={!error ? `${formDescriptionId}` : `${formDescriptionId} ${formMessageId}`}
          aria-invalid={!!error}
          {...props}
        />
      );
    }
    
    function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
      const { formDescriptionId } = useFormField();
    
      return (
        <p
          data-slot="form-description"
          id={formDescriptionId}
          className={cn("text-[13px] leading-normal text-muted-foreground", 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-[13px] text-destructive", className)}
          {...props}
        >
          {body}
        </p>
      );
    }
    
    export { useFormField, Form, FormItem, FormLabel, FormControl, FormDescription, FormMessage, FormField };
  3. Update the import paths to match your project

    The source imports cn from @/lib/utils and uses label.

Usage#

import {
  useFormField,
  Form,
  FormItem,
  FormLabel,
  FormControl,
  FormDescription,
  FormMessage,
  FormField,
} from "@/components/ui/form";
const form = useForm<z.infer<typeof schema>>({ resolver: zodResolver(schema) })

<Form {...form}>
  <form onSubmit={form.handleSubmit(onSubmit)}>
    <FormField
      control={form.control}
      name="name"
      render={({ field }) => (
        <FormItem>
          <FormLabel>Site name</FormLabel>
          <FormControl>
            <Input {...field} />
          </FormControl>
          <FormDescription>Shown in the hub directory.</FormDescription>
          <FormMessage />
        </FormItem>
      )}
    />
  </form>
</Form>

FormControl passes the id, aria-describedby and aria-invalid to its child, so FormLabel, FormDescription and FormMessage are linked to the control automatically.

Field is the lighter option: the same layout without react-hook-form, for forms you validate yourself.

Examples#

Select, switch and checkbox

Only listed players can join.