Skip to content
goodtabs UI
BrowseBrowse components

Form dialog

A dialog holding a validated form that returns focus to its trigger on close.

npx shadcn@latest add https://ui.goodtabs.co/r/form-dialog.json

With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/form-dialog

One required field with a custom validator. Submit with it empty to see focus move to the error.

Async submit

The button spins while the promise runs. A thrown error shows under the fields.

Usage

import { FormDialog } from "@/components/ui/form-dialog"

<FormDialog
  title="Invite a teammate"
  fields={[{ name: "email", label: "Work email", type: "email", required: true }]}
  onSubmit={async (v) => { await invite(v.email) }}
>
  <Button>Invite</Button>
</FormDialog>

Props

PropTypeDefaultDescription
childrenReactNodeThe trigger element, rendered with asChild.
titlestringWhat the form does, as a short phrase.
descriptionstringOne line of context under the title.
fieldsFormDialogField[]name, label, type, placeholder, hint, required and an optional validate function.
submitLabelstring"Save"Label of the submit button.
cancelLabelstring"Cancel"Label of the cancel button.
onSubmit(values: Record<string, string>) => void | Promise<void>Runs once every field passes. A promise shows a pending state; a throw keeps the dialog open.

Source

form-dialog/form-dialog.tsx
"use client";

import * as React from "react";
import { Loader2Icon } from "lucide-react";
import {
  Dialog,
  DialogClose,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { fieldDescribedBy, LabeledField } from "@/registry/goodtabs/labeled-field/labeled-field";

type FormDialogField = {
  /** Key in the values object passed to onSubmit. Also the input name. */
  name: string;
  label: string;
  type?: "text" | "email" | "url";
  placeholder?: string;
  hint?: string;
  required?: boolean;
  /** Return an error message, or undefined when the value is fine. Runs on submit and after the first error. */
  validate?: (value: string) => string | undefined;
};

type FormDialogProps = {
  /** The element that opens the dialog, usually a Button. Rendered with asChild. */
  children: React.ReactNode;
  title: string;
  description?: string;
  fields: FormDialogField[];
  submitLabel?: string;
  cancelLabel?: string;
  /** Runs with the values once every field passes. A promise shows a pending state; a thrown error stays on the form. */
  onSubmit: (values: Record<string, string>) => void | Promise<void>;
};

function check(field: FormDialogField, value: string) {
  if (field.required && !value.trim()) return `${field.label} is required.`;
  return field.validate?.(value.trim());
}

/**
 * A dialog that holds a validated form. It validates on submit, moves focus to the first
 * invalid field, and Radix returns focus to the trigger when the dialog closes.
 */
export function FormDialog({
  children,
  title,
  description,
  fields,
  submitLabel = "Save",
  cancelLabel = "Cancel",
  onSubmit,
}: FormDialogProps) {
  const uid = React.useId();
  const [open, setOpen] = React.useState(false);
  const [errors, setErrors] = React.useState<Record<string, string | undefined>>({});
  const [pending, setPending] = React.useState(false);
  const [formError, setFormError] = React.useState<string>();
  const formRef = React.useRef<HTMLFormElement>(null);

  const reset = () => {
    setErrors({});
    setFormError(undefined);
  };

  const handleOpenChange = (next: boolean) => {
    if (pending) return;
    if (!next) reset();
    setOpen(next);
  };

  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const data = new FormData(e.currentTarget);
    const values: Record<string, string> = {};
    const next: Record<string, string | undefined> = {};
    for (const f of fields) {
      values[f.name] = String(data.get(f.name) ?? "").trim();
      next[f.name] = check(f, values[f.name]);
    }
    setErrors(next);
    const first = fields.find((f) => next[f.name]);
    if (first) {
      formRef.current?.querySelector<HTMLInputElement>(`[name="${first.name}"]`)?.focus();
      return;
    }
    try {
      setPending(true);
      setFormError(undefined);
      await onSubmit(values);
      setOpen(false);
      reset();
    } catch (err) {
      setFormError(err instanceof Error ? err.message : "Something went wrong. Try again.");
    } finally {
      setPending(false);
    }
  };

  return (
    <Dialog open={open} onOpenChange={handleOpenChange}>
      <DialogTrigger asChild>{children}</DialogTrigger>
      <DialogContent showCloseButton={false} onEscapeKeyDown={(e) => pending && e.preventDefault()}>
        <form ref={formRef} noValidate onSubmit={handleSubmit} className="grid gap-4">
          <DialogHeader>
            <DialogTitle>{title}</DialogTitle>
            {description && <DialogDescription>{description}</DialogDescription>}
          </DialogHeader>
          <div className="grid gap-3">
            {fields.map((f) => {
              const id = `${uid}-${f.name}`;
              const error = errors[f.name];
              return (
                <LabeledField key={f.name} id={id} label={f.label} hint={f.hint} error={error} required={f.required}>
                  <Input
                    id={id}
                    name={f.name}
                    type={f.type ?? "text"}
                    placeholder={f.placeholder}
                    aria-invalid={error ? true : undefined}
                    aria-describedby={fieldDescribedBy(id, { hint: !!f.hint, error: !!error })}
                    onChange={(e) => {
                      if (errors[f.name]) setErrors((p) => ({ ...p, [f.name]: check(f, e.target.value) }));
                    }}
                  />
                </LabeledField>
              );
            })}
          </div>
          {formError && (
            <p role="alert" className="m-0 text-xs text-destructive">
              {formError}
            </p>
          )}
          <DialogFooter>
            <DialogClose asChild>
              <Button type="button" variant="outline" disabled={pending}>
                {cancelLabel}
              </Button>
            </DialogClose>
            <Button type="submit" disabled={pending} aria-busy={pending || undefined}>
              {pending && (
                <Loader2Icon
                  data-icon="inline-start"
                  className="animate-spin motion-reduce:animate-none"
                  aria-hidden="true"
                />
              )}
              {submitLabel}
            </Button>
          </DialogFooter>
        </form>
      </DialogContent>
    </Dialog>
  );
}

For AI agents

Hand your agent this page, the markdown at /components/form-dialog/prompt.md, or the Copy AI prompt button above.

When to use

  • Collecting two or three values without leaving the page
  • Create or rename flows where a failed submit must keep the input
  • Any short form that needs a pending and an error state

When not to use

  • Yes or no questions, use the confirm dialog
  • Long forms or multi-step flows, give them a page
  • Anything that needs fields other than text, email or url, compose Dialog yourself

Agent notes

  • onSubmit and validate are functions, so the component that renders FormDialog must be a client component
  • Fields are uncontrolled and read from FormData on submit; values are trimmed
  • Validation runs on submit, focus moves to the first invalid input, then errors re-check as the user types
  • Radix returns focus to the trigger on close; the dialog has no trigger-less mode, so wrap it around the element that opens it
  • While onSubmit is pending, Escape and Cancel are locked
  • Errors use text-destructive plus aria-invalid and role=alert, never colour alone