Dialog
PortedThe dialog panel, with its backdrop, in a portal. Put a DialogHeader, the content, and DialogActions in it. When the content can be taller than the screen, put it in a DialogBody: the body scrolls, and the title and the actions stay in place. While a dialog opened from it is open, the panel dims. Native <div> attributes and ref pass through.
import { TrashIcon } from "@phosphor-icons/react";
import { Button } from "@etched/monet/button";
import { Dialog, DialogActions, DialogClose, DialogDescription, DialogRoot, DialogTitle, DialogTrigger } from "@etched/monet/dialog";
export function Preview() {
return (
<DialogRoot role="alertdialog">
<DialogTrigger render={<Button variant="secondary-destructive" icon={<TrashIcon />}>Delete chip</Button>} />
<Dialog size="sm">
<DialogTitle>Delete chip SH-24A-0193?</DialogTitle>
<DialogDescription>Its runs and logs are deleted too. You cannot undo this.</DialogDescription>
<DialogActions>
<DialogClose render={<Button>Cancel</Button>} />
<DialogClose render={<Button variant="destructive">Delete</Button>} />
</DialogActions>
</Dialog>
</DialogRoot>
);
}Installation
import { Dialog, DialogActions, DialogBody, DialogClose, DialogDescription, DialogHeader, DialogRoot, DialogTitle, DialogTrigger } from "@etched/monet/dialog";Examples
Dialogs
import { Button } from "@etched/monet/button";
import { Combobox } from "@etched/monet/combobox";
import { Input } from "@etched/monet/input";
import { Select } from "@etched/monet/select";
import { Dialog, DialogActions, DialogClose, DialogDescription, DialogHeader, DialogRoot, DialogTitle, DialogTrigger } from "@etched/monet/dialog";
export function Dialogs() {
return (
<div className="row">
<DialogRoot>
<DialogTrigger render={<Button variant="primary">Assign owner</Button>} />
<Dialog size="lg">
<DialogHeader close>Assign owner</DialogHeader>
<DialogDescription>The owner gets alerts for chip SH-24A-0193.</DialogDescription>
<Select label="Owner" items={{ ana: "Ana Ruiz", sam: "Sam Lee", kai: "Kai Ito" }} placeholder="Choose an owner" />
<Combobox
label="Reviewer"
items={[
{ value: "ana", label: "Ana Ruiz" },
{ value: "sam", label: "Sam Lee" },
]}
placeholder="Choose a reviewer"
/>
<Input label="Note" placeholder="Optional note for the owner" />
<DialogActions>
<DialogClose render={<Button>Cancel</Button>} />
<DialogClose render={<Button variant="primary">Assign</Button>} />
</DialogActions>
</Dialog>
</DialogRoot>
<DialogRoot role="alertdialog">
<DialogTrigger render={<Button variant="secondary-destructive">Delete run</Button>} />
<Dialog size="sm">
<DialogTitle>Delete run 1,284?</DialogTitle>
<DialogDescription>Its results and logs are deleted too. You cannot undo this.</DialogDescription>
<DialogActions>
<DialogClose render={<Button>Cancel</Button>} />
<DialogClose render={<Button variant="destructive">Delete</Button>} />
</DialogActions>
</Dialog>
</DialogRoot>
</div>
);
}Long content
The fields scroll in a DialogBody; the title and the actions stay in place. A soft edge shows where the content is cut.
import { Button } from "@etched/monet/button";
import { Input, InputArea } from "@etched/monet/input";
import { Dialog, DialogActions, DialogBody, DialogClose, DialogDescription, DialogHeader, DialogRoot, DialogTrigger } from "@etched/monet/dialog";
const chipFields = [
["Name", "SH-24A-0193"],
["Lot", "7801"],
["Wafer", "W12"],
["Die", "D-0442"],
["Package", "FCBGA-2116"],
["Test site", "Santa Clara lab 2"],
["Board", "Bring-up board rev C"],
["Firmware", "2026.10.2"],
["Clock", "1.8 GHz"],
];
export function LongContent() {
return (
<DialogRoot>
<DialogTrigger render={<Button>Edit chip</Button>} />
<Dialog size="lg">
<DialogHeader close>Edit chip SH-24A-0193</DialogHeader>
<DialogDescription>Changes apply to new runs only.</DialogDescription>
<DialogBody>
{chipFields.map(([label, value]) => (
<Input key={label} label={label} defaultValue={value} />
))}
<InputArea label="Notes" rows={3} defaultValue="Runs hot above 1.9 GHz; keep the fan curve from lot 7799." />
</DialogBody>
<DialogActions>
<DialogClose render={<Button>Cancel</Button>} />
<DialogClose render={<Button variant="primary">Save</Button>} />
</DialogActions>
</Dialog>
</DialogRoot>
);
}Saving
While it saves, dismissDisabled keeps the dialog open: Escape, a click outside, the close button and Cancel do nothing, and the close buttons show disabled. Closing it from code (open) still works.
import { useState } from "react";
import { Button } from "@etched/monet/button";
import { Input } from "@etched/monet/input";
import { Dialog, DialogActions, DialogClose, DialogHeader, DialogRoot, DialogTrigger } from "@etched/monet/dialog";
export function Saving() {
const [open, setOpen] = useState(false);
const [busy, setBusy] = useState(false);
const save = () => {
setBusy(true);
setTimeout(() => {
setBusy(false);
setOpen(false);
}, 1200);
};
return (
<DialogRoot open={open} onOpenChange={setOpen} dismissDisabled={busy}>
<DialogTrigger render={<Button>Rename trace</Button>} />
<Dialog>
<DialogHeader close>Rename trace</DialogHeader>
<Input label="Name" defaultValue="Thermal soak, lot 7801" />
<DialogActions>
<DialogClose render={<Button>Cancel</Button>} />
<Button variant="primary" loading={busy} onClick={save}>
Save
</Button>
</DialogActions>
</Dialog>
</DialogRoot>
);
}Nested
A dialog can open another one, such as a confirmation. The first one steps back under the shade until the second closes. Escape closes only the top one, and focus returns to the button that opened it.
import { Button } from "@etched/monet/button";
import { Dialog, DialogActions, DialogClose, DialogDescription, DialogHeader, DialogRoot, DialogTitle, DialogTrigger } from "@etched/monet/dialog";
export function Nested() {
return (
<DialogRoot>
<DialogTrigger render={<Button>Manage reviewers</Button>} />
<Dialog>
<DialogHeader close>Reviewers</DialogHeader>
<DialogDescription>Ana Ruiz and Sam Lee review every run of chip SH-24A-0193.</DialogDescription>
<DialogActions>
<DialogRoot role="alertdialog">
<DialogTrigger render={<Button variant="secondary-destructive">Remove all</Button>} />
<Dialog size="sm">
<DialogTitle>Remove all reviewers?</DialogTitle>
<DialogDescription>Runs of this chip go unreviewed until you add one.</DialogDescription>
<DialogActions>
<DialogClose render={<Button>Cancel</Button>} />
<DialogClose render={<Button variant="destructive">Remove</Button>} />
</DialogActions>
</Dialog>
</DialogRoot>
<DialogClose render={<Button variant="primary">Done</Button>} />
</DialogActions>
</Dialog>
</DialogRoot>
);
}In a container
container renders the dialog into an element of the app, such as a shadow root or a region with its own theme, in place of the page's body.
import { useRef } from "react";
import { Button } from "@etched/monet/button";
import { Dialog, DialogDescription, DialogHeader, DialogRoot, DialogTrigger } from "@etched/monet/dialog";
export function InAContainer() {
const region = useRef<HTMLDivElement>(null);
return (
<div ref={region}>
<DialogRoot>
<DialogTrigger render={<Button>Show run log</Button>} />
<Dialog container={region}>
<DialogHeader close>Run 1,284 log</DialogHeader>
<DialogDescription>This dialog renders inside the demo's own element.</DialogDescription>
</Dialog>
</DialogRoot>
</div>
);
}Accessibility
- Focus moves into the dialog when it opens, and
Tab/Shift+Tabstay inside.Escapecloses the top dialog, and focus goes back to its trigger. - A
dialog, or analertdialogwithrole="alertdialog"onDialogRoot.DialogTitlenames it andDialogDescriptiondescribes it;aria-labelnames one with no title. - While it is open, the page behind is hidden from screen readers. A
DialogBodythat scrolls and has no control is aTabstop, for scrolling. dismissDisabledstopsEscape, outside clicks and the close buttons, which show disabled. An alert dialog does not close on an outside click.- Use
role="alertdialog"to confirm a delete. SetfinalFocuswhen the trigger is gone after the dialog closes, such as a deleted row's button.
API reference
DialogHeader
| Prop | Type | Default | Description |
|---|---|---|---|
| children * | ReactNode | – | The title. |
| close | boolean | – | Shows a close button at the end of the title's line. |
| closeLabel | string | "Close" | Names the close button. |
Dialog
| Prop | Type | Default | Description |
|---|---|---|---|
| size | "sm" | "base" | "lg" | "xl" | "base" | |
| initialFocus | PopupProps["initialFocus"] | – | What gets focus when the dialog opens, for example a ref to one field. By default, the first focusable element. |
| finalFocus | PopupProps["finalFocus"] | – | What gets focus when the dialog closes. By default, the trigger. |
| container | PortalProps["container"] | document.body | The element the dialog renders into, such as a shadow root or a themed region. |
| aria-label | string | – | Names a dialog that has no visible DialogTitle, such as a search dialog. |
DialogRoot
| Prop | Type | Default | Description |
|---|---|---|---|
| dismissDisabled | boolean | – | While true, the user cannot close the dialog, for example while it saves: Escape, a click outside and the close buttons do nothing, and the close buttons show disabled. Closing it from code (open) still works. |
| role | "dialog" | "alertdialog" | – |