Skip to content

Form dialog

A dialog around a short create or edit form, with Cancel and a filled submit in the footer band.

Status
Beta
Category
Overlays
Adoption
Not used yet
import { FormDialog } from "@oration/canon/components/form-dialog";
packages/canon/src/components/form-dialog.tsx
import { Button } from "@oration/canon/components/button";import { Field, FieldLabel } from "@oration/canon/components/field";import { FormDialog } from "@oration/canon/components/form-dialog";import { Input } from "@oration/canon/components/input";import { OptionSelect } from "@oration/canon/components/option-select";import { toast } from "@oration/canon/components/toast";import { PlusIcon } from "lucide-react";import * as React from "react";export function Hero() {    const [open, setOpen] = React.useState(false);    const [name, setName] = React.useState("");    const [email, setEmail] = React.useState("");    const [terms, setTerms] = React.useState("Net 30");    const nameId = React.useId();    const emailId = React.useId();    const termsId = React.useId();    return (        <>            <Button                type="button"                onClick={() => {                    setName("");                    setEmail("");                    setTerms("Net 30");                    setOpen(true);                }}            >                <PlusIcon data-icon="inline-start" aria-hidden="true" />                New supplier            </Button>            <FormDialog                open={open}                onOpenChange={setOpen}                title="New supplier"                description="We email a W-9 request to the remit-to address."                submitLabel="Add supplier"                onSubmit={(event) => {                    event.preventDefault();                    setOpen(false);                    toast.add({                        type: "success",                        title: `${name.trim() || "Supplier"} added`,                        description: email                            ? `W-9 request sent to ${email}.`                            : undefined,                    });                }}            >                <Field>                    <FieldLabel htmlFor={nameId}>Legal name</FieldLabel>                    <Input                        id={nameId}                        autoFocus                        autoComplete="off"                        placeholder="Northwind Freight LLC"                        value={name}                        onChange={(event) => setName(event.target.value)}                    />                </Field>                <Field>                    <FieldLabel htmlFor={emailId}>Remit-to email</FieldLabel>                    <Input                        id={emailId}                        type="email"                        autoComplete="off"                        placeholder="ap@northwindfreight.com"                        value={email}                        onChange={(event) => setEmail(event.target.value)}                    />                </Field>                <Field>                    <FieldLabel htmlFor={termsId}>Terms</FieldLabel>                    <OptionSelect                        id={termsId}                        label="Terms"                        value={terms}                        onValueChange={setTerms}                        options={["Net 15", "Net 30", "Net 45", "Net 60"]}                    />                </Field>            </FormDialog>        </>    );}

Usage#

Form dialog is the ready-made dialog for creating or editing one record with a few fields: New supplier, Add W-9 contact, Edit payment terms. It supplies the header, a <form> with a Field group spaced 16px apart, and a footer band with Cancel and a filled submit, so every create dialog in the suite looks and submits the same way. You bring the fields and the onSubmit. What people get wrong: it doesn't validate, show errors or track pending state for you. Prevent the default, validate in onSubmit, mark the fields invalid, and close it yourself.

When to use

  • To create a record from a list or a record header: New supplier, New invoice, Add contact.
  • To edit two to six fields of one record where the page behind doesn't need to stay visible.
  • When a quick-create from a combobox or a menu item needs a short form, such as adding a cost center without leaving the invoice.

When not to use

  • For a long edit, or details people compare with the list behind. Keep the list in view. Use Sheet
  • For a form that is the page, such as supplier settings. Save it in place. Use Save bar
  • For a yes or no with no fields. Use Confirm dialog
  • For a layout the props can't express: a scrolling list, a stepper, a split body. Use Dialog
  • For setup that spans several pages, such as connecting a bank. Use Stacked dialog

The One Filled Button Rule

The submit is the dialog's one filled button, last in the footer. Cancel is outline. A delete in an edit dialog goes on the left in the red tint, through the footer prop.

Every field has a label

Each control inside is a Field with a visible FieldLabel, or an sr-only one when a group heading already names it. Placeholders are examples, never labels.

Anatomy#

New supplier

We email a W-9 request.

Legal name
Northwind Freight LLC
  1. Header. DialogTitle names the record (New supplier) and the optional description says what happens to it. Sits outside the form.
  2. Fields. Your children, inside a FieldGroup with 16px between fields. Pair short fields in a two-column grid.
  3. Footer band. The Dialog footer band, inside the form, 20px below the last field. Replace it wholesale with footer.
  4. Cancel. An outline button that calls onOpenChange(false). Labelled by cancelLabel.
  5. Submit. type="submit", filled, labelled by submitLabel. Disable it with submitDisabled only while a submit is in flight.
  6. Close button. The Dialog close button in the top right. Always shown; there is no prop to hide it.

Examples#

Validation on submit

The form is noValidate, so check the values in onSubmit, mark each failing Field with data-invalid and its control with aria-invalid, show a FieldError and move focus to the first one. The submit stays enabled.

import { Button } from "@oration/canon/components/button";import { Field, FieldError, FieldLabel } from "@oration/canon/components/field";import { FormDialog } from "@oration/canon/components/form-dialog";import { Input } from "@oration/canon/components/input";import { toast } from "@oration/canon/components/toast";import { PlusIcon } from "lucide-react";import * as React from "react";export function Validation() {    const [open, setOpen] = React.useState(false);    const [name, setName] = React.useState("");    const [email, setEmail] = React.useState("maya.okafor@cedarline");    const [errors, setErrors] = React.useState<{        name?: string;        email?: string;    }>({});    const nameId = React.useId();    const emailId = React.useId();    const nameRef = React.useRef<HTMLInputElement>(null);    const emailRef = React.useRef<HTMLInputElement>(null);    return (        <>            <Button                type="button"                variant="outline"                onClick={() => {                    setErrors({});                    setOpen(true);                }}            >                <PlusIcon data-icon="inline-start" aria-hidden="true" />                Add contact            </Button>            <FormDialog                open={open}                onOpenChange={setOpen}                title="Add contact"                description="Contacts at Halcyon get remittance advice and W-9 reminders."                submitLabel="Add contact"                onSubmit={(event) => {                    event.preventDefault();                    const next: typeof errors = {};                    if (!name.trim()) next.name = "Enter a name.";                    if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim()))                        next.email =                            "That email is missing a domain, such as .com.";                    setErrors(next);                    if (next.name) nameRef.current?.focus();                    else if (next.email) emailRef.current?.focus();                    else {                        setOpen(false);                        toast.add({                            type: "success",                            title: `${name.trim()} added to Halcyon`,                        });                    }                }}            >                <Field data-invalid={errors.name ? true : undefined}>                    <FieldLabel htmlFor={nameId}>Name</FieldLabel>                    <Input                        ref={nameRef}                        id={nameId}                        autoComplete="off"                        aria-invalid={errors.name ? true : undefined}                        value={name}                        onChange={(event) => setName(event.target.value)}                    />                    {errors.name ? (                        <FieldError>{errors.name}</FieldError>                    ) : null}                </Field>                <Field data-invalid={errors.email ? true : undefined}>                    <FieldLabel htmlFor={emailId}>Email</FieldLabel>                    <Input                        ref={emailRef}                        id={emailId}                        type="email"                        autoComplete="off"                        aria-invalid={errors.email ? true : undefined}                        value={email}                        onChange={(event) => setEmail(event.target.value)}                    />                    {errors.email ? (                        <FieldError>{errors.email}</FieldError>                    ) : null}                </Field>            </FormDialog>        </>    );}

Two columns

Pair short fields in a grid and widen the dialog with contentClassName. It replaces the default, so always include a width.

import { Button } from "@oration/canon/components/button";import { Field, FieldDescription, FieldLabel } from "@oration/canon/components/field";import { FormDialog } from "@oration/canon/components/form-dialog";import { Input } from "@oration/canon/components/input";import { Textarea } from "@oration/canon/components/textarea";import { toast } from "@oration/canon/components/toast";import { PlusIcon } from "lucide-react";import * as React from "react";export function TwoColumns() {    const [open, setOpen] = React.useState(false);    const ids = {        number: React.useId(),        amount: React.useId(),        issued: React.useId(),        due: React.useId(),        memo: React.useId(),    };    return (        <>            <Button                type="button"                variant="outline"                onClick={() => setOpen(true)}            >                <PlusIcon data-icon="inline-start" aria-hidden="true" />                New invoice            </Button>            <FormDialog                open={open}                onOpenChange={setOpen}                title="New invoice from Orchard Street"                description="It goes to Priya Raman for approval."                submitLabel="Create invoice"                contentClassName="sm:max-w-lg"                onSubmit={(event) => {                    event.preventDefault();                    setOpen(false);                    toast.add({                        type: "success",                        title: "Invoice created",                        description: "Waiting on Priya Raman's approval.",                    });                }}            >                <div className="grid gap-4 sm:grid-cols-2">                    <Field>                        <FieldLabel htmlFor={ids.number}>                            Invoice number                        </FieldLabel>                        <Input                            id={ids.number}                            autoFocus                            defaultValue="OS-11873"                            className="font-mono"                        />                    </Field>                    <Field>                        <FieldLabel htmlFor={ids.amount}>Amount</FieldLabel>                        <Input                            id={ids.amount}                            inputMode="decimal"                            defaultValue="6,875.40"                            className="tabular-nums"                        />                    </Field>                    <Field>                        <FieldLabel htmlFor={ids.issued}>Issued</FieldLabel>                        <Input                            id={ids.issued}                            type="date"                            defaultValue="2026-09-28"                        />                    </Field>                    <Field>                        <FieldLabel htmlFor={ids.due}>Due</FieldLabel>                        <Input                            id={ids.due}                            type="date"                            defaultValue="2026-10-28"                        />                    </Field>                </div>                <Field>                    <FieldLabel htmlFor={ids.memo}>Memo (optional)</FieldLabel>                    <Textarea                        id={ids.memo}                        placeholder="Q3 landscaping, east campus"                    />                    <FieldDescription>                        Shown on the remittance advice.                    </FieldDescription>                </Field>            </FormDialog>        </>    );}

States#

States
StateTreatment
OpenFocus goes to the first focusable element, usually the first field. Put autoFocus on it to be explicit.
InvalidAfter a failed submit, the fields you mark with data-invalid on Field and aria-invalid on the control draw the red border and ring, with a FieldError below.
SubmittingNot built in. Pass submitDisabled or a custom footer with a Pending button while the request runs.
ClosedThe fields unmount. Reset your form state when it opens, not when it closes, so the closing animation doesn't show empty fields.

Behavior#

  • Controlled only: open and onOpenChange are required. Esc, the scrim, the close button and Cancel all call onOpenChange(false).
  • onSubmit receives the native form event. Call event.preventDefault(), validate, and on success close the dialog and confirm with a toast.
  • The form has noValidate, so the browser's own bubbles never appear. Validate in onSubmit and show messages with FieldError.
  • Enter in any single-line input submits the form. The default Cancel is type="button", so it never submits.
  • footer replaces the default footer and renders inside the form, so a type="submit" button in it still submits.
  • Width defaults to 28rem through contentClassName="sm:max-w-md". Pass sm:max-w-lg for two columns of fields.
  • Motion is Dialog's: 200ms in from 0.96, 140ms out toward 0.98.

Choosing an overlay#

Seven components take over the screen. Pick by the question the overlay asks: a yes or no goes to a confirmation, a few fields to a form dialog, details that belong beside a list to a sheet. For a few controls tied to one button, use a popover instead.

Which overlay to use
ComponentReach for it whenShapeDismissed by
DialogA focused task the page waits for: a short form, a review, a setting with its own Save. You compose the header, body and footer.Centered, 24rem by default, up to 48remEsc, a click on the scrim, the close button
Confirm dialogConfirming one consequential action: delete, remove, rotate, move. Add a typed name when the loss is large.Centered, 24rem, 28rem when typedEsc or an answer. The scrim doesn't close it
Alert dialogA decision Confirm dialog can't express, such as three answers or a media tile. You build the parts; Confirm dialog is built on it.Centered, 20 or 24remEsc or an answer. The scrim doesn't close it
Form dialog (this page)Creating or editing one record with two to six fields, a submit and a cancel.Centered, 28remEsc, the scrim, Cancel, the close button
Stacked dialogThree to six pages read or completed in order: what's new, connecting a provider.Centered, 28rem, with a control barEsc, the scrim, Done, the close button
SheetRecord details, logs or a longer edit that should keep the list it came from in view.From an edge, 24rem wide by defaultEsc, the scrim, the close button
DrawerTouch-first flows on phones that people expect to swipe away. Not used in the product yet.From the bottom, up to the viewport minus 6remA swipe, Esc, the scrim

Do and don't#

New supplier

Halcyon Logistics LLC
Do. Label the submit with the verb and the object: Add supplier, Save terms.

New supplier

Halcyon Logistics LLC
Don't. Leave it as Submit or OK, or leave submitLabel out and ship an empty button.

Add contact

Email
wen.zhou@halcyon
That email is missing a domain, such as .com.
Do. Keep the submit enabled and explain what's missing when it's pressed, next to the field.

Add contact

Email
wen.zhou@halcyon
Don't. Disable the submit until the form is valid. Nobody can tell why it won't press.
Do. Ask only for what's needed to create the record. Everything else is edited on the record after.
Don't. Put twelve fields and a tab strip in a form dialog. That's a sheet or a page.

Content#

  • Title: New supplier for create, Edit payment terms for edit. Sentence case, no period.
  • Description: one sentence on what happens after, such as We email a W-9 request to the remit-to address. Leave it out rather than restating the title.
  • Labels are nouns: Legal name, Remit-to email, Terms. Mark optional fields with (optional) rather than marking required ones.
  • Errors say what to do: Enter a legal name., That email is missing an @.
  • Submit repeats the title's verb and object; Cancel stays Cancel.

Accessibility#

  • It is a Dialog: role="dialog", labelled by the title and described by the description.
  • Every control needs a label through FieldLabel htmlFor. For a Select trigger without an input, give it an aria-label too.
  • Mark invalid controls with aria-invalid and render the message in FieldError, which has role="alert", so it's announced on submit.
  • Move focus to the first invalid field after a failed submit.
  • Focus returns to the trigger when it closes.
Keyboard interactions
KeysAction
TabMoves through the fields and buttons.
EnterSubmits the form from any single-line input.
EscCloses without saving.

Design tokens#

Design tokens
TokenUsed for
--popoverSurface fill
--mutedFooter band at 50%
--primaryThe submit button
--destructiveInvalid field border and ring, error text
shadow-lgThe dialog shadow

API reference#

FormDialog

A controlled Dialog around a form. Takes no other props; everything else goes on your fields.

Props of FormDialog
PropTypeDefaultDescription
openRequiredbooleanNo defaultWhether it is open.
onOpenChangeRequired(open: boolean) => voidNo defaultCalled by Esc, the scrim, the close button and Cancel.
titleRequiredReact.ReactNodeNo defaultNames the record or the task.
descriptionReact.ReactNodeNo defaultOne sentence under the title. Omitted when empty.
onSubmitRequiredReact.FormEventHandler<HTMLFormElement>No defaultRuns on submit. Prevent the default, validate and close it yourself.
childrenRequiredReact.ReactNodeNo defaultThe fields, rendered in a FieldGroup with gap-4.
submitLabelReact.ReactNodeNo defaultThe submit button's label. There is no default.
submitDisabledbooleanNo defaultDisables the submit button.
cancelLabelReact.ReactNode"Cancel"The cancel button's label.
contentClassNamestring"sm:max-w-md"Classes for DialogContent. Replaces the default width, so include one.
footerReact.ReactNodeNo defaultReplaces the default footer. Render a DialogFooter with your own buttons.

Known gaps#

Where the implementation and the system disagree today. Follow the system, not the gap.

The registry describes it as handling submit, pending and errors. It handles none of them: there is no pending state, no error slot and no validation, and the form doesn't prevent its default.

submitLabel has no default, so leaving it out renders an empty filled button.

The close button can't be hidden; the component doesn't forward showCloseButton.

contentClassName replaces the default rather than merging with it, so passing p-0 alone drops the 28rem width.

Only five files in the product use it, while 32 files compose DialogContent around a <form> by hand, most with the same header, field group and footer.