Form dialog
A dialog around a short create or edit form, with Cancel and a filled submit in the footer band.
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
footer prop.Every field has a label
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.
- Header.
DialogTitlenames the record (New supplier) and the optional description says what happens to it. Sits outside the form. - Fields. Your
children, inside aFieldGroupwith 16px between fields. Pair short fields in a two-column grid. - Footer band. The Dialog footer band, inside the form, 20px below the last field. Replace it wholesale with
footer. - Cancel. An outline button that calls
onOpenChange(false). Labelled bycancelLabel. - Submit.
type="submit", filled, labelled bysubmitLabel. Disable it withsubmitDisabledonly while a submit is in flight. - 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#
| State | Treatment |
|---|---|
| Open | Focus goes to the first focusable element, usually the first field. Put autoFocus on it to be explicit. |
| Invalid | After 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. |
| Submitting | Not built in. Pass submitDisabled or a custom footer with a Pending button while the request runs. |
| Closed | The 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:
openandonOpenChangeare required. Esc, the scrim, the close button and Cancel all callonOpenChange(false). onSubmitreceives the native form event. Callevent.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 inonSubmitand show messages withFieldError. - Enter in any single-line input submits the form. The default Cancel is
type="button", so it never submits. footerreplaces the default footer and renders inside the form, so atype="submit"button in it still submits.- Width defaults to 28rem through
contentClassName="sm:max-w-md". Passsm:max-w-lgfor 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.
| Component | Reach for it when | Shape | Dismissed by |
|---|---|---|---|
| Dialog | A 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 48rem | Esc, a click on the scrim, the close button |
| Confirm dialog | Confirming one consequential action: delete, remove, rotate, move. Add a typed name when the loss is large. | Centered, 24rem, 28rem when typed | Esc or an answer. The scrim doesn't close it |
| Alert dialog | A 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 24rem | Esc 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, 28rem | Esc, the scrim, Cancel, the close button |
| Stacked dialog | Three to six pages read or completed in order: what's new, connecting a provider. | Centered, 28rem, with a control bar | Esc, the scrim, Done, the close button |
| Sheet | Record details, logs or a longer edit that should keep the list it came from in view. | From an edge, 24rem wide by default | Esc, the scrim, the close button |
| Drawer | Touch-first flows on phones that people expect to swipe away. Not used in the product yet. | From the bottom, up to the viewport minus 6rem | A swipe, Esc, the scrim |
Do and don't#
New supplier
New supplier
submitLabel out and ship an empty button.Add contact
Add contact
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 anaria-labeltoo. - Mark invalid controls with
aria-invalidand render the message inFieldError, which hasrole="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.
| Keys | Action |
|---|---|
| Tab | Moves through the fields and buttons. |
| Enter | Submits the form from any single-line input. |
| Esc | Closes without saving. |
Design tokens#
| Token | Used for |
|---|---|
--popover | Surface fill |
--muted | Footer band at 50% |
--primary | The submit button |
--destructive | Invalid field border and ring, error text |
shadow-lg | The dialog shadow |
API reference#
FormDialog
A controlled Dialog around a form. Takes no other props; everything else goes on your fields.
| Prop | Type | Default | Description |
|---|---|---|---|
openRequired | boolean | No default | Whether it is open. |
onOpenChangeRequired | (open: boolean) => void | No default | Called by Esc, the scrim, the close button and Cancel. |
titleRequired | React.ReactNode | No default | Names the record or the task. |
description | React.ReactNode | No default | One sentence under the title. Omitted when empty. |
onSubmitRequired | React.FormEventHandler<HTMLFormElement> | No default | Runs on submit. Prevent the default, validate and close it yourself. |
childrenRequired | React.ReactNode | No default | The fields, rendered in a FieldGroup with gap-4. |
submitLabel | React.ReactNode | No default | The submit button's label. There is no default. |
submitDisabled | boolean | No default | Disables the submit button. |
cancelLabel | React.ReactNode | "Cancel" | The cancel button's label. |
contentClassName | string | "sm:max-w-md" | Classes for DialogContent. Replaces the default width, so include one. |
footer | React.ReactNode | No default | Replaces 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.