Dialog
When to use
Section titled “When to use”- 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:
| Component | Use it for | For example |
|---|---|---|
| Dialog | Something that needs a decision or input before the user carries on; the page is blocked meanwhile | Confirm a delete, edit a record in a form |
| Drawer | A panel that slides in from an edge and blocks the page; the first choice on a phone | Share options, a mobile nav, a filter panel |
| Popover | Extra content or controls next to a button, while the rest of the page stays usable | Filter panel, share options, details card |
| Menu | A list of actions or settings opened from a button | “More actions ⋯”, a View menu |
| Tooltip | A short text label on hover or focus; nothing in it can be clicked | Name of an icon button, a keyboard shortcut |
| HoverCard | A preview of where a link goes, on hover or focus; nothing essential in it | A profile behind an @mention, a repository’s summary |
| Toast | A brief confirmation after an action, which goes away by itself | “Saved”, “Archived — Undo” |
| Alert | A 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.
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>} </> );}Anatomy
Section titled “Anatomy”import { Dialog } from '@arun-dev/ui';
<Dialog.Root> <Dialog.Trigger /> <Dialog.Popup> <Dialog.Title /> <Dialog.Close /> </Dialog.Popup></Dialog.Root>;| Part | Renders | Does |
|---|---|---|
Root | nothing | holds 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.
Closing
Section titled “Closing”Every close is a request through onOpenChange(false):
| How | Notes |
|---|---|
Dialog.Close | |
| Esc | the platform’s cancel event, prevented and routed through the Root |
| backdrop click | on 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.
Controlled
Section titled “Controlled”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.
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.
Events
Section titled “Events”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.
| Part | Renders | Its 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:
| Prop | Fires |
|---|---|
onCancel | on Esc, before it closes. event.preventDefault() keeps it open |
onClose | after any close — Close, Esc, backdrop or a form |
onBeforeToggle | before each open and close; event.newState is 'open' or 'closed' |
onToggle | after 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.
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.
Accessibility
Section titled “Accessibility”- Name the dialog with
Dialog.Title. Without one, passaria-labeltoDialog.Popup. - Focus moves to the first focusable element inside when it opens. Put
autoFocuson 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
Section titled “Dialog.Root”| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | Controlled state. Provide onOpenChange alongside it. Never pass undefined — the mode is fixed at mount. | |
defaultOpen | boolean | false | Initial state when uncontrolled. Read once, at mount. |
onOpenChange | (open: boolean) => void | Called 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. | |
closeOnBackdropClick | boolean | true | Close when the backdrop is clicked. Turn it off where a stray click would lose work; Esc and Dialog.Close still close. |
children | ReactNode | The other parts. Root renders no element of its own. |
Dialog.Popup
Section titled “Dialog.Popup”| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Concatenated with the component's own classes, never replacing them. | |
render | ReactElement | Element or component to render instead of the default. Props, className, event handlers and ref are merged onto it. | |
ref | Ref<HTMLElement> | Ref to the rendered element. Merged with any ref on the render element. | |
…rest | HTMLAttributes | Anything 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>isdisplay: 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, andprefers-reduced-motionturns it off. - Restyle every dialog with the
--dialog-*tokens, e.g.--dialog-widthor--dialog-backdrop. - Types come from the package that owns them:
import type { DialogRootProps } from '@arun-dev/headless/dialog', orComponentProps<typeof Dialog.Root>.