Form Field
Inputs & Forms

Form Field

A form composition primitive that wires label, control, description, and error together via useId and aria-describedby, with the error row animating open and closed through grid-template-rows.

Install

npx shadcn@latest add @paragon/form-field

form-field.tsx

"use client";

import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { CircleAlert } from "lucide-react";
import { cn } from "@/lib/utils";

interface FormFieldContextValue {
  id: string;
  descriptionId: string;
  messageId: string;
  error?: string;
  required: boolean;
  disabled: boolean;
  hasDescription: boolean;
  registerDescription: () => () => void;
}

const FormFieldContext = React.createContext<FormFieldContextValue | null>(
  null,
);

/**
 * Read the current field's wiring (ids, error, required, disabled) from
 * context — for building custom controls that participate in a FormField.
 */
export function useFormField() {
  const context = React.useContext(FormFieldContext);
  if (!context) {
    throw new Error("useFormField must be used within a <FormField>.");
  }
  return context;
}

export interface FormFieldProps extends React.ComponentProps<"div"> {
  /** Current error message. Truthy = invalid; drives aria and the message. */
  error?: string;
  /** Marks the label with an asterisk and sets aria-required on the control. */
  required?: boolean;
  /** Dims the label and description. The control manages its own disabled. */
  disabled?: boolean;
}

/**
 * The composition primitive for consistent forms: wires a label, any control,
 * a description, and an animated error message together via useId —
 * htmlFor, aria-describedby, and aria-invalid all land on the right nodes
 * without any manual id plumbing.
 */
export function FormField({
  error,
  required = false,
  disabled = false,
  className,
  children,
  ...props
}: FormFieldProps) {
  const id = React.useId();
  const [descriptionCount, setDescriptionCount] = React.useState(0);

  const registerDescription = React.useCallback(() => {
    setDescriptionCount((count) => count + 1);
    return () => setDescriptionCount((count) => count - 1);
  }, []);

  const context = React.useMemo<FormFieldContextValue>(
    () => ({
      id: `${id}-control`,
      descriptionId: `${id}-description`,
      messageId: `${id}-message`,
      error,
      required,
      disabled,
      hasDescription: descriptionCount > 0,
      registerDescription,
    }),
    [id, error, required, disabled, descriptionCount, registerDescription],
  );

  return (
    <FormFieldContext.Provider value={context}>
      <div
        data-slot="form-field"
        data-invalid={error ? true : undefined}
        data-disabled={disabled || undefined}
        className={cn("group/field flex w-full flex-col", className)}
        {...props}
      >
        {children}
      </div>
    </FormFieldContext.Provider>
  );
}

export function FormLabel({
  className,
  children,
  ...props
}: React.ComponentProps<"label">) {
  const { id, error, required, disabled } = useFormField();
  return (
    <label
      data-slot="form-label"
      htmlFor={id}
      className={cn(
        "mb-1.5 flex items-baseline gap-1 text-sm font-medium text-foreground",
        "transition-[color] duration-150 ease-out",
        error && "text-destructive",
        disabled && "opacity-50",
        className,
      )}
      {...props}
    >
      {children}
      {required && (
        <span aria-hidden className="text-destructive">
          *
        </span>
      )}
    </label>
  );
}

/**
 * Slot that injects id, aria-describedby, aria-invalid, and aria-required
 * onto whatever single control it wraps — a native input, a textarea, or a
 * custom component that forwards props.
 */
export function FormControl(props: React.ComponentProps<typeof Slot>) {
  const { id, error, required, descriptionId, messageId, hasDescription } =
    useFormField();
  return (
    <Slot
      data-slot="form-control"
      id={id}
      aria-invalid={error ? true : undefined}
      aria-required={required || undefined}
      aria-describedby={
        cn(hasDescription && descriptionId, error && messageId) || undefined
      }
      {...props}
    />
  );
}

export function FormDescription({
  className,
  ...props
}: React.ComponentProps<"p">) {
  const { descriptionId, registerDescription, disabled } = useFormField();
  // Register so FormControl only references this id when it actually exists.
  React.useEffect(() => registerDescription(), [registerDescription]);
  return (
    <p
      data-slot="form-description"
      id={descriptionId}
      className={cn(
        "mt-1.5 text-[13px] text-muted-foreground",
        disabled && "opacity-50",
        className,
      )}
      {...props}
    />
  );
}

export interface FormMessageProps extends React.ComponentProps<"div"> {
  /** Overrides the context error as the rendered message. */
  children?: React.ReactNode;
}

/**
 * The animated error region. Always mounted, so the collapse can animate:
 * the row expands via grid-template-rows 0fr -> 1fr while the text fades and
 * slides in; on clear it collapses with the exit ease while the last message
 * is kept visible so it never pops out mid-animation.
 */
export function FormMessage({ className, children, ...props }: FormMessageProps) {
  const { messageId, error } = useFormField();
  const content = children ?? error;
  const open = Boolean(content);

  // Keep the previous message rendered while the row collapses.
  const lastContent = React.useRef<React.ReactNode>(null);
  React.useEffect(() => {
    if (content) lastContent.current = content;
  }, [content]);
  const display = content ?? lastContent.current;

  return (
    <div
      data-slot="form-message"
      data-state={open ? "open" : "closed"}
      aria-hidden={!open || undefined}
      className={cn(
        "grid transition-[grid-template-rows] motion-reduce:transition-none",
        className,
      )}
      style={{
        gridTemplateRows: open ? "1fr" : "0fr",
        transitionDuration: open ? "200ms" : "150ms",
        transitionTimingFunction: open ? "var(--ease-out)" : "var(--ease-exit)",
      }}
      {...props}
    >
      <div className="min-h-0 overflow-hidden">
        <p
          id={messageId}
          role={open ? "alert" : undefined}
          className={cn(
            "flex items-start gap-1.5 pt-1.5 text-[13px] text-destructive",
            "transition-[opacity,translate,filter] motion-reduce:transition-[opacity]",
            open
              ? "translate-y-0 opacity-100 blur-none duration-200 ease-[var(--ease-out)]"
              : "-translate-y-1 opacity-0 blur-[2px] duration-150 ease-[var(--ease-exit)]",
          )}
        >
          <CircleAlert aria-hidden className="mt-px size-3.5 shrink-0" />
          <span className="min-w-0">{display}</span>
        </p>
      </div>
    </div>
  );
}