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
| Prop | Type | Default | Description |
|---|---|---|---|
| children | ReactNode | The trigger element, rendered with asChild. | |
| title | string | What the form does, as a short phrase. | |
| description | string | One line of context under the title. | |
| fields | FormDialogField[] | name, label, type, placeholder, hint, required and an optional validate function. | |
| submitLabel | string | "Save" | Label of the submit button. |
| cancelLabel | string | "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