Monet
@etched/monet 0.0.1

Dialog

Ported

The 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.

Kumo source
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+Tab stay inside. Escape closes the top dialog, and focus goes back to its trigger.
  • A dialog, or an alertdialog with role="alertdialog" on DialogRoot. DialogTitle names it and DialogDescription describes it; aria-label names one with no title.
  • While it is open, the page behind is hidden from screen readers. A DialogBody that scrolls and has no control is a Tab stop, for scrolling.
  • dismissDisabled stops Escape, 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. Set finalFocus when the trigger is gone after the dialog closes, such as a deleted row's button.

API reference

DialogHeader

PropTypeDefaultDescription
children *ReactNode–The title.
closeboolean–Shows a close button at the end of the title's line.
closeLabelstring"Close"Names the close button.

Dialog

PropTypeDefaultDescription
size"sm" | "base" | "lg" | "xl""base"
initialFocusPopupProps["initialFocus"]–What gets focus when the dialog opens, for example a ref to one field. By default, the first focusable element.
finalFocusPopupProps["finalFocus"]–What gets focus when the dialog closes. By default, the trigger.
containerPortalProps["container"]document.bodyThe element the dialog renders into, such as a shadow root or a themed region.
aria-labelstring–Names a dialog that has no visible DialogTitle, such as a search dialog.

DialogRoot

PropTypeDefaultDescription
dismissDisabledboolean–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"–