Skip to content

Dialog

  • Confirming something destructive: “Delete this project?”
  • A short form that must be completed or cancelled before carrying on
  • A focused task that should block the page until done

Not for information that can sit beside the page — use a Popover — or a message that needs no decision — use an Alert or a Toast.

Choosing between messages and popups:

ComponentUse it forFor example
DialogSomething that needs a decision or input before the user carries on; the page is blocked meanwhileConfirm a delete, edit a record in a form
DrawerA panel that slides in from an edge and blocks the page; the first choice on a phoneShare options, a mobile nav, a filter panel
PopoverExtra content or controls next to a button, while the rest of the page stays usableFilter panel, share options, details card
MenuA list of actions or settings opened from a button“More actions ⋯”, a View menu
TooltipA short text label on hover or focus; nothing in it can be clickedName of an icon button, a keyboard shortcut
HoverCardA preview of where a link goes, on hover or focus; nothing essential in itA profile behind an @mention, a repository’s summary
ToastA brief confirmation after an action, which goes away by itself“Saved”, “Archived — Undo”
AlertA message that stays in the page until the situation changes“Your trial ends in 3 days”, a form error summary

Dialog is a real <dialog>, opened with showModal(). The platform supplies the hard parts: the top layer (no portal, no z-index), the backdrop, an inert page behind it, a focus trap, and focus returning to the trigger on close. @arun-dev/headless adds the state, one path every close goes through, and the ARIA wiring.

Delete report.pdf?

It will be moved to the bin for 30 days.

import { useState } from 'react';
import { Button, Dialog } from '@arun-dev/ui';
const footer = {
display: 'flex',
justifyContent: 'flex-end',
gap: 'var(--space-xs)',
marginBlockStart: 'var(--space-lg)',
};
export default function DialogBasics() {
const [deleted, setDeleted] = useState(false);
return (
<>
<Dialog.Root>
{/* Trigger and Close are plain buttons: render a Button to style them. */}
<Dialog.Trigger render={<Button />}>Delete file…</Dialog.Trigger>
<Dialog.Popup>
<Dialog.Title>Delete report.pdf?</Dialog.Title>
<p style={{ margin: 0 }}>It will be moved to the bin for 30 days.</p>
<div style={footer}>
<Dialog.Close render={<Button />}>Cancel</Dialog.Close>
<Dialog.Close render={<Button variant="primary" />} onClick={() => setDeleted(true)}>
Delete
</Dialog.Close>
</div>
</Dialog.Popup>
</Dialog.Root>
{deleted && <p style={{ margin: 0 }}>Deleted.</p>}
</>
);
}
import { Dialog } from '@arun-dev/ui';
<Dialog.Root>
<Dialog.Trigger />
<Dialog.Popup>
<Dialog.Title />
<Dialog.Close />
</Dialog.Popup>
</Dialog.Root>;
PartRendersDoes
Rootnothingholds open, and the setter every other part goes through
Trigger<button>opens; carries aria-haspopup, aria-expanded, aria-controls
Popup<dialog>the modal itself; aria-labelledby points at the Title
Title<h2>the heading, and the dialog’s accessible name
Close<button>closes

Trigger and Close are unstyled buttons, so render a Button to style them: <Dialog.Close render={<Button />}>Cancel</Dialog.Close>. A Close can do work too — give it an onClick, as the Delete button above does. Trigger is optional; a dialog opened from state alone needs none.

There is no Portal or Backdrop part. The top layer sits above everything without being moved in the DOM, and the backdrop is the ::backdrop pseudo-element, styled by --dialog-backdrop.

Every close is a request through onOpenChange(false):

HowNotes
Dialog.Close
Escthe platform’s cancel event, prevented and routed through the Root
backdrop clickon by default; closeOnBackdropClick={false} turns it off
a form<form method="dialog"> closes natively, and the Root is told

A press that starts inside the dialog and is released on the backdrop — a text selection dragged out — does not count as a backdrop click.

Pass open and onOpenChange to own the state. Every request arrives through onOpenChange, and the dialog moves only if you accept it, so refusing a close is simply not setting state.

New issue

import { useState } from 'react';
import { Button, Dialog, Input, Textarea } from '@arun-dev/ui';
const field = { display: 'grid', gap: 'var(--space-3xs)' };
const footer = { display: 'flex', justifyContent: 'flex-end', gap: 'var(--space-xs)' };
export default function DialogForm() {
const [open, setOpen] = useState(false);
const [saved, setSaved] = useState<string | null>(null);
return (
<>
{/* Controlled, and the backdrop is inert: a stray click must not lose the draft. */}
<Dialog.Root open={open} onOpenChange={setOpen} closeOnBackdropClick={false}>
<Dialog.Trigger render={<Button variant="primary" />}>New issue</Dialog.Trigger>
<Dialog.Popup>
<Dialog.Title>New issue</Dialog.Title>
<form
style={{ display: 'grid', gap: 'var(--space-md)' }}
onSubmit={(event) => {
event.preventDefault();
setSaved(String(new FormData(event.currentTarget).get('title')));
setOpen(false);
}}
>
<div style={field}>
<label htmlFor="dialog-issue-title">Title</label>
<Input id="dialog-issue-title" name="title" required />
</div>
<div style={field}>
<label htmlFor="dialog-issue-body">Description</label>
<Textarea id="dialog-issue-body" name="body" autoResize />
</div>
<div style={footer}>
<Dialog.Close render={<Button />}>Cancel</Dialog.Close>
<Button type="submit" variant="primary">
Create
</Button>
</div>
</form>
</Dialog.Popup>
</Dialog.Root>
{saved && <p style={{ margin: 0 }}>Created “{saved}”.</p>}
</>
);
}

This one also turns off closeOnBackdropClick, since a stray click would lose a half-written issue. Esc and Cancel still close it.

Stopping Esc yourself

onCancel on Dialog.Popup sees Esc first. event.preventDefault() keeps the dialog open, as it does on a plain <dialog>. event.preventComponentHandler() instead hands Esc back to the platform, which closes the element; the Root is still told.

Where to attach them. Each part renders a real element and passes your props on to it, so any event that element fires — not only those listed here — is a prop on that part. Dialog.Root renders nothing, so it has only onOpenChange.

PartRendersIts events, for example
Dialog.Popup<dialog>onCancel, onClose, onBeforeToggle, onToggle, onKeyDown, onTransitionEnd
Dialog.Trigger, Dialog.Close<button>onClick, onFocus, onKeyDown

onOpenChange on the Root reports every open and close, but not what caused it. The dialog’s own events on Dialog.Popup add a little more. Yours run before the component’s:

PropFires
onCancelon Esc, before it closes. event.preventDefault() keeps it open
onCloseafter any close — Close, Esc, backdrop or a form
onBeforeTogglebefore each open and close; event.newState is 'open' or 'closed'
onToggleafter each open and close — the one event for opening as well as closing

The toggle events on a dialog need Chrome 132, Firefox 133 or Safari 26. event.preventDefault() in onBeforeToggle stops an open, not a close; the dialog stays shut and onOpenChange(false) reports it, so the state and the Trigger follow.

Close me

Press Esc, click the backdrop, or use a button.

Events appear here.
import { useState } from 'react';
import { Button, Dialog } from '@arun-dev/ui';
const footer = {
display: 'flex',
justifyContent: 'flex-end',
gap: 'var(--space-xs)',
marginBlockStart: 'var(--space-lg)',
};
const logStyle = {
margin: 0,
minBlockSize: '7.5rem',
fontFamily: 'var(--font-mono, monospace)',
fontSize: 'var(--text-xs)',
color: 'var(--color-text-secondary)',
};
export default function DialogEvents() {
const [log, setLog] = useState<string[]>([]);
const add = (line: string) => setLog((lines) => [...lines.slice(-5), line]);
return (
<div style={{ display: 'grid', gap: 'var(--space-xs)', justifyItems: 'start' }}>
<Dialog.Root onOpenChange={(open) => add(`onOpenChange(${open})`)}>
<Dialog.Trigger render={<Button />}>Open, then close it any way</Dialog.Trigger>
{/* Native events on the Popup: yours run first, and they say how it closed. */}
<Dialog.Popup onCancel={() => add('onCancel — Esc')} onClose={() => add('onClose')}>
<Dialog.Title>Close me</Dialog.Title>
<p style={{ margin: 0 }}>Press Esc, click the backdrop, or use a button.</p>
<div style={footer}>
<form method="dialog">
<Button type="submit">Form submit</Button>
</form>
<Dialog.Close render={<Button variant="primary" />}>Close</Dialog.Close>
</div>
</Dialog.Popup>
</Dialog.Root>
<pre style={logStyle} aria-live="polite">
{log.length ? log.join('\n') : 'Events appear here.'}
</pre>
<Button onClick={() => setLog([])}>Clear log</Button>
</div>
);
}

So Esc is the close you can single out, by onCancel. A Close button or a form is yours, so its own onClick or onSubmit identifies it; a backdrop click is what remains.

onOpenChange(false) is a request, so it can arrive before the dialog is closed: a Close button, Esc or the backdrop report first, then the element closes; a form closes the element first. For work that needs it actually closed, use onClose, which always comes after.

Not reported: opening it by calling showModal() yourself — use open and onOpenChange instead — or unmounting it while open.

  • Name the dialog with Dialog.Title. Without one, pass aria-label to Dialog.Popup.
  • Focus moves to the first focusable element inside when it opens. Put autoFocus on a different one to choose it — for a destructive confirmation, the safe button.
  • Focus returns to whatever had it before, usually the Trigger, when it closes.
  • The page behind is inert and does not scroll.
Dialog.Root props
PropTypeDefaultDescription
openbooleanControlled state. Provide onOpenChange alongside it. Never pass undefined — the mode is fixed at mount.
defaultOpenbooleanfalseInitial state when uncontrolled. Read once, at mount.
onOpenChange(open: boolean) => voidCalled on every request to open or close: Trigger, Close, Esc, a backdrop click or a <form method="dialog">. A controlled dialog moves only if you accept it.
closeOnBackdropClickbooleantrueClose when the backdrop is clicked. Turn it off where a stray click would lose work; Esc and Dialog.Close still close.
childrenReactNodeThe other parts. Root renders no element of its own.
Dialog.Popup props
PropTypeDefaultDescription
classNamestringConcatenated with the component's own classes, never replacing them.
renderReactElementElement or component to render instead of the default. Props, className, event handlers and ref are merged onto it.
refRef<HTMLElement>Ref to the rendered element. Merged with any ref on the render element.
…restHTMLAttributesAnything else is spread onto the rendered element, so id, aria-*, data-* and event handlers all reach the DOM.

Dialog.Trigger, Dialog.Title and Dialog.Close take the props of the element they render — <button>, <h2>, <button> — plus render and ref.

  • The content stays mounted while closed — a closed <dialog> is display: none — so a form inside keeps what was typed between openings. Reset it yourself if it shouldn’t.
  • Dialogs nest: open one from inside another, and Esc closes only the top one.
  • Opening and closing fade and rise, using @starting-style. A browser without it just skips the animation, and prefers-reduced-motion turns it off.
  • Restyle every dialog with the --dialog-* tokens, e.g. --dialog-width or --dialog-backdrop.
  • Types come from the package that owns them: import type { DialogRootProps } from '@arun-dev/headless/dialog', or ComponentProps<typeof Dialog.Root>.