Overlays & navigation
Dialog
Dialogs interrupt the page for a focused task or a decision. Plinth builds them on the native <dialog> element, so the browser handles the focus trap, the inert background and Escape; the component adds sizes, a header that names the dialog for screen readers, a scrolling body, a footer that stacks on phones, and a ready-made confirm pattern for destructive actions.
On this page
export function InviteDialog() { const [open, setOpen] = useState(false); return ( <div className="flex justify-center"> <Button variant="primary" iconLeft={<UserPlus strokeWidth={1.75} />} onClick={() => setOpen(true)}> Invite teammates </Button> <Dialog open={open} onClose={() => setOpen(false)}> <DialogHeader icon={<UserPlus strokeWidth={1.75} />} tone="accent" title="Invite to Fernhill Studio" description="Teammates get access to every project in this workspace." /> <DialogBody className="flex flex-col gap-4"> <Field label="Email addresses" hint="Separate several addresses with commas."> <Input placeholder="ada@fernhill.example" data-autofocus /> </Field> <Field label="Role"> <Select defaultValue="member"> <option value="admin">Admin</option> <option value="member">Member</option> <option value="viewer">Viewer</option> </Select> </Field> </DialogBody> <DialogFooter> <DialogClose> <Button variant="outline">Cancel</Button> </DialogClose> <Button variant="primary" onClick={() => setOpen(false)}>Send invites</Button> </DialogFooter> </Dialog> </div> );}Usage
import { useState } from "react";import { Button } from "@/components/ui/button";import { Dialog, DialogBody, DialogClose, DialogFooter, DialogHeader } from "@/components/ui/dialog"; export function Example() { const [open, setOpen] = useState(false); return ( <> <Button onClick={() => setOpen(true)}>Open</Button> <Dialog open={open} onClose={() => setOpen(false)}> <DialogHeader title="Archive invoice" description="It moves to Archive and stops sending reminders." /> <DialogFooter> <DialogClose><Button variant="outline">Cancel</Button></DialogClose> <Button variant="primary" onClick={() => setOpen(false)}>Archive</Button> </DialogFooter> </Dialog> </> );}Examples
Form dialog
Header with an icon, form fields in the body, actions in the footer. `data-autofocus` picks the field that gets focus on open.
export function InviteDialog() { const [open, setOpen] = useState(false); return ( <div className="flex justify-center"> <Button variant="primary" iconLeft={<UserPlus strokeWidth={1.75} />} onClick={() => setOpen(true)}> Invite teammates </Button> <Dialog open={open} onClose={() => setOpen(false)}> <DialogHeader icon={<UserPlus strokeWidth={1.75} />} tone="accent" title="Invite to Fernhill Studio" description="Teammates get access to every project in this workspace." /> <DialogBody className="flex flex-col gap-4"> <Field label="Email addresses" hint="Separate several addresses with commas."> <Input placeholder="ada@fernhill.example" data-autofocus /> </Field> <Field label="Role"> <Select defaultValue="member"> <option value="admin">Admin</option> <option value="member">Member</option> <option value="viewer">Viewer</option> </Select> </Field> </DialogBody> <DialogFooter> <DialogClose> <Button variant="outline">Cancel</Button> </DialogClose> <Button variant="primary" onClick={() => setOpen(false)}>Send invites</Button> </DialogFooter> </Dialog> </div> );}Destructive confirmation
ConfirmDialog with `tone="danger"` is an alertdialog: a warning icon, a red confirm button, Cancel focused first and no backdrop dismiss. `confirmText` asks for the name before the button enables.
export function DeleteProject() { const [open, setOpen] = useState(false); return ( <> <Button variant="danger" onClick={() => setOpen(true)}>Delete project</Button> <ConfirmDialog open={open} onClose={() => setOpen(false)} onConfirm={() => deleteProject("fernhill-web")} tone="danger" title="Delete fernhill-web?" description="This permanently deletes the project, its 14 deployments and all environment variables. This can't be undone." confirmText="fernhill-web" confirmLabel="Delete project" /> </> );}Simple confirmation
The default tone for decisions that are easy to undo.
export function PublishConfirm() { const [open, setOpen] = useState(false); return ( <div className="flex justify-center"> <Button onClick={() => setOpen(true)}>Publish changes</Button> <ConfirmDialog open={open} onClose={() => setOpen(false)} onConfirm={() => {}} title="Publish to production?" description="Your 3 changes go live on ledgerly.example for every visitor." confirmLabel="Publish" /> </div> );}Sizes
Four max widths. On phones every size fills the screen minus a 16px margin.
export function DialogSizes() { const [size, setSize] = useState<DialogSize | null>(null); return ( <div className="flex flex-wrap justify-center gap-2"> {(["sm", "md", "lg", "xl"] as const).map((s) => ( <Button key={s} variant="outline" onClick={() => setSize(s)}>Size {s}</Button> ))} <Dialog open={size !== null} onClose={() => setSize(null)} size={size ?? "md"}> <DialogHeader title={`A ${size} dialog`} description="sm 384px, md 512px, lg 672px, xl 896px. Phones always get the full width minus a margin." /> <DialogFooter> <DialogClose> <Button variant="primary">Got it</Button> </DialogClose> </DialogFooter> </Dialog> </div> );}Long content
The body scrolls while the header and footer stay in view. `divider` draws the lines that separate them.
<Dialog open={open} onClose={() => setOpen(false)} size="lg"> <DialogHeader title="Terms of service" description="Updated March 4. Please read before continuing." divider /> <DialogBody className="flex flex-col gap-4 py-5 text-muted"> {sections.map(([title, text]) => ( <section key={title} className="flex flex-col gap-1"> <h3 className="m-0 text-[14px] font-semibold text-fg">{title}</h3> <p className="m-0">{text}</p> </section> ))} </DialogBody> <DialogFooter divider> <DialogClose> <Button variant="outline">Decline</Button> </DialogClose> <Button variant="primary" onClick={() => setOpen(false)}>Accept and continue</Button> </DialogFooter></Dialog>Modal shorthand
Modal is the one-component version: title, description, body and footer as props. It renders the same Dialog.
<Modal open={open} onClose={() => setOpen(false)} title="Rename workspace" description="The name shows in the sidebar and on invoices." footer={ <> <Button variant="outline" onClick={() => setOpen(false)}>Cancel</Button> <Button variant="primary" onClick={() => setOpen(false)}>Save</Button> </> }> <Field label="Workspace name"> <Input defaultValue="Fernhill Studio" /> </Field></Modal>Props
Dialog
openrequiredbooleandefault: —Whether the dialog is shown. The component calls showModal() and close() for you.onCloserequired() => voiddefault: —Called for Escape, a backdrop click, the close button and DialogClose. Set open to false here.size"sm" | "md" | "lg" | "xl"default: "md"Max width: 384, 512, 672 or 896px.role"dialog" | "alertdialog"default: "dialog"Use alertdialog for confirmations. It also turns off backdrop dismiss by default.position"center" | "top"default: "center"Top pins the dialog near the top of the screen, like a command palette.closeOnBackdropbooleandefault: trueClose when the backdrop is clicked. Defaults to false for alertdialog.closeOnEscapebooleandefault: trueClose on Escape. Turn off only while a task is in progress.aria-labelstringdefault: —Name for dialogs without a DialogHeader.
DialogHeader
titlerequiredReactNodedefault: —Heading. Also the dialog's accessible name.descriptionReactNodedefault: —Supporting text, linked with aria-describedby.iconReactNodedefault: —Lucide icon in a tinted square before the title.tone"neutral" | "accent" | "danger"default: "neutral"Tint of the icon square.hideClosebooleandefault: falseHide the × button (the footer must offer a way out).dividerbooleandefault: falseLine under the header, for scrolling bodies.
DialogBody, DialogFooter, DialogClose
DialogBody{ className, children }default: —Content area. Scrolls when the dialog is taller than the screen.DialogFooter{ divider?, className, children }default: —Actions. Stacked full-width on phones (primary on top), right-aligned from 640px.DialogClose{ children: ReactElement }default: —Wraps a button so a click closes the dialog.useDialog()() => { close }default: —Close the surrounding dialog from any child component.
ConfirmDialog
open / onCloserequiredboolean / () => voiddefault: —Same as Dialog.onConfirmrequired() => voiddefault: —Called when the confirm button is pressed; the dialog then closes.titlerequiredstringdefault: —The question, e.g. “Delete fernhill-web?”.descriptionReactNodedefault: —What happens and whether it can be undone.tone"default" | "danger"default: "default"Danger adds a warning icon, a red button and focuses Cancel first.confirmTextstringdefault: —Text the user must type before confirming.confirmLabel / cancelLabelstringdefault: "Confirm" / "Cancel"Button labels. Use a verb that names the action.
Accessibility
- Built on <dialog> with showModal(): the rest of the page is inert, Tab stays inside, and focus returns to the trigger on close.
- Escape closes the dialog. For alertdialogs a backdrop click does not, so a stray click can't confirm or cancel by accident.
- DialogHeader's title names the dialog (aria-labelledby) and the description is read with it (aria-describedby). Header-less dialogs need aria-label.
- On open, focus goes to the element with data-autofocus, otherwise to the first focusable element. Destructive confirmations focus Cancel.
- The × button has the accessible name “Close”. Page scrolling is locked while a dialog is open.