Dialogcomponent

Modal <dialog> element that opens on mount and includes a close button.

  • Opens via showModal() when mounted and closes on backdrop clicks, link/nav-button clicks, the close button, or the Escape key.
  • The whole dialog fades in and out in one view transition. A <Modal> pinned to an edge leaves that layer and slides in its own.
  • With onClose, a close request calls onClose() and the dialog stays open until it unmounts, so the view transition can capture it as it leaves.
  • Children sit in one wrapper in normal block layout, --dialog-width wide and centred on the screen. Content taller than the screen scrolls from its top.
  • Wraps content in <Suspense> so lazy children can stream in.

A native <dialog> element opened in modal mode. It opens via showModal() when mounted and includes a built-in close button, so it works equally well mounted declaratively in the tree or pushed imperatively through a DialogsStore.

Things to know:

  • Closes on a backdrop click, the Escape key, any link or <nav> button clicked inside it, or the built-in <DialogCloseButton> (an X icon, top-right).
  • Children render inside a <Suspense> boundary, so lazy content can stream in.
  • Children sit in one wrapper in normal block layout, so several children stack as they would on the page. The wrapper is --dialog-width wide (never wider than the screen) and centred on the screen. A centred <Modal> fills it. Content taller than the screen starts at the top, and the dialog scrolls.
  • While a dialog is open, the page behind it does not scroll. A scroll inside the dialog never passes on to the page.
  • Dialog only dims the page. Its text is white (--tint-100) so it reads on the dark overlay. Wrap the content in <Modal> to give it a panel with dark text on a light surface.
  • onClose fires when the user closes the dialog. It must unmount the Dialog: clear the React state that mounts it, or (when pushed via a store) remove it from the list. Dialog calls onClose inside startTransition(), and the dialog stays open until it unmounts.
  • The dialog animates with view transitions. The whole dialog fades in and out as one layer. A <Modal> pinned to an edge takes its own layer and slides.
  • A view transition only runs for a React transition update. DialogsStore and onClose do this for you. To animate a declarative Dialog as it opens, set the state that mounts it inside startTransition().
  • A browser without view transitions shows and hides the dialog at once.
  • Pair with DialogsStore, <DialogsContext>, and <Dialogs> to open dialogs imperatively from anywhere in the app.

Usage

Declarative

Mount <Dialog> directly when its lifetime matches a React state variable.

tsx
import { Dialog, Modal } from "shelving/ui";

function ConfirmDelete({ onConfirm, onClose }: { onConfirm: () => void; onClose: () => void }) {
  return (
    <Dialog onClose={onClose}>
      <Modal>
        <p>Delete this item?</p>
        <button type="button" onClick={onConfirm}>Delete</button>
      </Modal>
    </Dialog>
  );
}

// In the parent. Open inside `startTransition()` so the dialog animates in.
<button type="button" onClick={() => startTransition(() => setShowConfirm(true))}>Delete</button>
{showConfirm && <ConfirmDelete onConfirm={handleDelete} onClose={() => setShowConfirm(false)} />}

Imperative

Set up the context once near the app root (see <DialogsContext> and <Dialogs>), then push a <Dialog> from anywhere with requireDialogs().

tsx
import { Modal, requireDialogs } from "shelving/ui";

function DeleteButton({ onConfirm }: { onConfirm: () => void }) {
  const dialogs = requireDialogs();
  const open = () =>
    dialogs.show(
      <Modal>
        <p>Delete this item?</p>
        <button type="button" onClick={onConfirm}>Delete</button>
      </Modal>,
    );
  return <button type="button" onClick={open}>Delete</button>;
}

dialogs.show() wraps the content in a <Dialog> for you, so you pass plain children rather than a <Dialog> element. It does not add a <Modal>; include one in the content if you want a panel.

Styling

Dialog paints the full-screen overlay and resets the browser's default <dialog> border, size limits, and ::backdrop. Transparent borders as wide as the screen's safe areas keep the content and the close button clear of the notch, rounded corners and home bar. The inner panel comes from its children, usually <Modal>. Override these hooks at :root (or any ancestor scope) to retheme.

VariableStylesDefault
--dialog-paddingTop and bottom padding around the centred content (one length)var(--space-normal) (16px)
--dialog-indentLeft and right padding around the centred content (one length)var(--space-normal) (16px)
--dialog-widthWidth of the centred content, and so of a centred <Modal>var(--width-narrow) (36rem)
--dialog-backgroundOverlay fill behind the contentvar(--color-backdrop)
--dialog-colorText colour directly on the overlayvar(--tint-100) (white)
--dialog-close-offsetInset of the close button from the top-right cornervar(--space-small) (12px)

The fade uses the fade class from <FadeTransition>, so --fade-transition-duration sets its length. It runs only as the dialog opens and closes; an open dialog stays still while other dialogs open and close.

Global tokens it reads — move these to retheme broadly: --tint-100, --width-narrow, --space-normal, --space-small, --color-backdrop, and --duration-fast (through <FadeTransition>).