Dialog
A modal dialog built on Base UI Dialog — focus trapping, scroll locking, and
Escape / outside-click dismissal are handled for you. The panel animates in
and out via Base UI’s data-[starting-style] / data-[ending-style] hooks.
Use Dialog for every modal decision, including destructive ones: a short form,
a confirm, an info panel. Dismissing a confirm is the safe answer — it means the
action didn’t happen — so outside click stays enabled by default. Lock it with
disablePointerDismissal only when the panel holds something that would be lost
(typed input), or when it opened without the user asking.
Import
import { Dialog } from "@photon-ai/pho-ui/components/dialog";
Anatomy of Dialog.Content
Dialog.Content is the convenience panel. It composes portal, backdrop, and
popup, then layers:
- Header — an optional
iconabovetitle/description, and optionally a header close - Body — an optional
consequenceslist andwarningline, thenchildren(forms, copy, errors) - Actions —
actionsfooter row, right-aligned
<Dialog.Root>
<Dialog.Trigger render={<Button variant="outlined">Open</Button>} />
<Dialog.Content title="…" description="…" actions={/* footer buttons */}>
{/* optional body */}
</Dialog.Content>
</Dialog.Root>
Skip Content and assemble Portal / Backdrop / Popup yourself when the
layout doesn’t fit this shape (see Custom layout).
Close vs actions
Iron rule: when actions is set, the header close is never shown. Dismiss
through the action row (and Escape / outside click, unless you disable those).
When there is no actions row, a header close is shown by default so the panel
always has an explicit dismiss control.
| Pattern | actions |
Header close |
|---|---|---|
| Confirm / form with buttons | set | hidden |
| Info / read-only panel | omitted | shown |
| Info, but you want no X either | omitted | showClose={false} |
Left: delete confirm with actions — no X. Right: info panel without actions
— header close on the title row.
{
/* Actions → no header close */
}
<Dialog.Content
title="Delete project"
description="This cannot be undone."
actions={
<>
<Dialog.Close render={<Button variant="ghost">Cancel</Button>} />
<Dialog.Close render={<Button color="destructive">Delete</Button>} />
</>
}
/>;
{
/* No actions → header close */
}
<Dialog.Content
title="What's new in Photon"
description="You can now invite teammates and pin projects."
>
<p>More detail in the body…</p>
</Dialog.Content>;
The close control sits in the title row: vertically centered on the title line, out of document flow so it does not stretch the row, and may paint slightly outside that line. It only appears when the iron rule allows it.
Header icon
Pass icon for a decorative glyph above the title — it names the dialog’s
subject at a glance (a phone for verification, a key for credentials, a check
for success). The slot owns the treatment: aria-hidden, secondary ink, and a
hair of air before the title. Panel dialogs (Content / InsetContent) use
32px ([&>svg]:size-8); FullscreenContent steps up to 48px
([&>svg]:size-12) so the glyph reads at viewport scale.
Ink yields to a styled node — a success confirmation hands the slot a filled check in success ink and changes nothing else.
{/* Default treatment — secondary ink, 32px */}
<Dialog.Content
icon={<IconDeviceMobile stroke={1.5} />}
title="Verify your phone number"
description="We'll text a 6-digit code to confirm it's yours."
actions={…}
/>
{/* Override the ink for a state — the slot passes your classes through */}
<Dialog.Content
icon={<IconCircleCheckFilled className="text-pho-success" />}
title="Number verified"
description="+1 415 555 0123 is now linked to your account."
actions={<Dialog.Close render={<Button>Done</Button>} />}
/>
Consequences
For destructive confirms where the one-line description undersells the impact,
pass consequences — an array of short outcomes rendered as a bulleted list at
the top of the body (what is deleted, what survives, who loses access). Keep
each item to a single sentence; the list styling (description ink, spacing,
markers) is owned by the component. To emphasize the load-bearing figure, wrap
it in <strong> — the list styles it uniformly (secondary ink, medium weight),
so callers never hand-write classes. Pair with warning for the final red
“cannot be undone” line.
<Dialog.Content
title="Delete this project?"
description="This permanently deletes Aurora and all of its data."
consequences={[
"The agent profile and all project settings are deleted.",
"Past invoices stay archived on your account.",
<>
<strong>3 members</strong> lose access immediately.
</>,
]}
warning="It cannot be undone. Please be certain."
actions={
<>
<Dialog.Close render={<Button variant="ghost">Cancel</Button>} />
<Dialog.Close
render={<Button color="destructive">Delete project</Button>}
/>
</>
}
/>
With a form body
Put fields in children and keep Cancel / Submit in actions. The footer is
laid out for you (flex justify-end gap-2); you only pass the buttons.
<Dialog.Content
title="Create a project"
description="Give the new Photon project a name."
className="max-w-md"
actions={
<>
<Dialog.Close render={<Button variant="ghost">Cancel</Button>} />
<Dialog.Close
render={<Button disabled={!name.trim()}>Create project</Button>}
/>
</>
}
>
<Input
autoFocus
label="Project name"
value={name}
onChange={(event) => setName(event.target.value)}
/>
</Dialog.Content>
Controlled
Leave Dialog.Root uncontrolled and the trigger owns open state. To open from
elsewhere, pass open / onOpenChange and close with setOpen(false) when
the work finishes — don’t wrap that confirm in Dialog.Close if it must wait.
(For the async-submit case itself, Async actions below does
this wiring for you.)
const [open, setOpen] = useState(false);
const [pending, setPending] = useState(false);
async function subscribe() {
setPending(true);
await saveSubscription();
setPending(false);
setOpen(false);
}
<Dialog.Root open={open} onOpenChange={setOpen}>
<Dialog.Content
title="Subscribe"
description="Send product updates to your inbox."
actions={
<>
<Dialog.Close
render={
<Button variant="ghost" disabled={pending}>
Not now
</Button>
}
/>
<Button loading={pending} onClick={() => void subscribe()}>
Subscribe
</Button>
</>
}
/>
</Dialog.Root>;
Async actions
For the common case, skip the state wiring entirely: Dialog.Action is a
confirm Button with native promise support. Return a promise from its
onClick and the button shows loading, the dialog refuses every dismissal
(outside press, Escape, Dialog.Close) while the work is in flight, and it
closes itself when the promise resolves. On rejection the dialog stays open —
surface the error inside your handler. Use useDialogPending() to grey out
secondary actions while pending.
import { Dialog, useDialogPending } from "@photon-ai/pho-ui/components/dialog";
function Cancel() {
const pending = useDialogPending();
return (
<Dialog.Close
render={
<Button variant="ghost" disabled={pending}>
Not now
</Button>
}
/>
);
}
<Dialog.Root>
<Dialog.Trigger render={<Button variant="outlined">Open dialog</Button>} />
<Dialog.Content
title="Subscribe"
description="Send product updates to your inbox."
actions={
<>
<Cancel />
<Dialog.Action onClick={async () => await saveSubscription()}>
Subscribe
</Dialog.Action>
</>
}
/>
</Dialog.Root>;
Overflow
Every popup caps at the inset panel’s footprint (100dvh minus 1rem per
edge, 2rem per edge from sm). The body between header and actions is a
scroll container — with ordinary content nothing changes, but content taller
than the cap scrolls inside while the header and action row stay pinned.
The scroll edges are progressive: rows approaching an edge blur and wash
into the surface instead of hard-clipping, tracking scroll distance
(Scroll Area). Nothing to wire.
<Dialog.Content title="Terms of service" actions={…}>
…more content than a viewport can hold…
</Dialog.Content>
Info panel
No footer buttons — omit actions so the header close appears. Body copy goes
in children.
<Dialog.Content
title="What's new in Photon"
description="You can now invite teammates, pin projects, and review usage."
>
<p className="text-pho-secondary text-base">
Dismiss with the header close, Escape, or an outside click.
</p>
</Dialog.Content>
Inset panel
Between the floating card and the full takeover sits Dialog.InsetContent:
the viewport minus a breathing ring (inset-4, sm:inset-8), floating over
the usual backdrop so the page stays visible — and dimmed — through the gap.
It is still a card (border, elevation, rounded corners), so the scale gesture
stays, shrunk to 98% where the floating card’s 95% would read like a jump.
The panel is a column: header pinned on top, body filling and scrolling on
its own (overflow-y-auto), optional action row pinned at the bottom. Use it
for roomy working surfaces — pickers, editors, previews — that outgrow a
centered card but don’t warrant a page takeover. The close/actions iron rule
carries over unchanged.
<Dialog.Root>
<Dialog.Trigger
render={<Button variant="outlined">Choose template</Button>}
/>
<Dialog.InsetContent
title="Choose a template"
description="Pick a starting point for the new project."
actions={
<>
<Dialog.Close render={<Button variant="ghost">Cancel</Button>} />
<Dialog.Close render={<Button>Use template</Button>} />
</>
}
>
{/* grid, editor, preview… the body scrolls by itself */}
</Dialog.InsetContent>
</Dialog.Root>
For a bespoke layout at this size, compose Dialog.InsetPopup directly — it
is the inset-sized sibling of Popup (same backdrop pairing, motion, and
frame) and leaves everything inside to you.
Full-screen gate
Dialog.FullscreenContent turns the whole viewport into the dialog — a
page-surface takeover instead of a floating card. There is no backdrop (the
popup itself is the layer), the content sits in a centered 28rem column
(the same measure as the standalone onboarding / invite cards), and the
motion is fade-only — scale is a floating-card gesture and reads wrong at
viewport size.
Reach for it when the visitor must deal with something before the page behind
makes sense: binding a phone number, accepting new terms, finishing a required
step. Everything conversational stays in Content. The close/actions iron
rule carries over unchanged: with actions set there is never a corner X.
// Mount closed, then flip open in an effect so the enter fade plays.
const [open, setOpen] = useState(false);
useEffect(() => {
if (needsPhone) setOpen(true);
}, [needsPhone]);
<Dialog.Root open={open} onOpenChange={setOpen}>
<Dialog.FullscreenContent
title="Verify your phone number"
description="Photon uses your number to secure the account."
actions={
<>
<Button variant="ghost" onClick={dismiss}>
Not now
</Button>
<Button disabled={!valid} onClick={sendCode}>
Send code
</Button>
</>
}
>
<PhoneInput value={phone} onValueChange={setPhone} />
</Dialog.FullscreenContent>
</Dialog.Root>;
One mounting note: enter transitions play on a closed → open flip, so a gate
that should be visible immediately must still mount closed and flip open in
its first effect — <Dialog.Root open> from the first render paints with no
fade.
For a bespoke full-screen layout, compose Dialog.FullscreenPopup directly —
it is the viewport-sized sibling of Dialog.Popup (same fade, page surface,
scrollable) and leaves everything inside to you.
Custom layout
For layouts the header doesn’t cover, skip Dialog.Content and assemble the
primitives — Portal, Backdrop, Popup, with Title / Description /
Close wherever you need them. You own the close affordance in that case;
mirror the iron rule yourself (action footers without a competing X).
<Dialog.Root>
<Dialog.Trigger render={<Button>Open</Button>} />
<Dialog.Portal>
<Dialog.Backdrop />
<Dialog.Popup className="p-6">
<Dialog.Title>Rename file</Dialog.Title>
<Dialog.Description>Choose a new name for this file.</Dialog.Description>
<Input label="Name" className="mt-4" />
<div className="mt-5 flex justify-end gap-2">
<Dialog.Close render={<Button variant="ghost">Cancel</Button>} />
<Dialog.Close render={<Button>Save</Button>} />
</div>
</Dialog.Popup>
</Dialog.Portal>
</Dialog.Root>
Parts
- Dialog.Root — owns open state; controlled (
open/onOpenChange) or uncontrolled (defaultOpen). - Dialog.Trigger — opens the dialog; use
renderto reuse aButton. - Dialog.Content — convenience panel: header + body +
actions, with the close/actions iron rule built in. - Dialog.Portal → Backdrop → Popup — low-level layers when you need full control.
- Dialog.InsetContent — near-fullscreen floating panel: pinned header/actions, self-scrolling body, backdrop kept. Same props as
Content(icon,title,description,actions,showClose, …). - Dialog.InsetPopup — the inset-sized
Popupsibling, for bespoke layouts at that size. - Dialog.FullscreenContent — viewport takeover for gates: page surface, centered 28rem column, same iron rule.
- Dialog.FullscreenPopup — the viewport-sized
Popupsibling, for bespoke full-screen layouts. - Dialog.Title / Dialog.Description — accessible name and description.
- Dialog.Close — wrap any control so it dismisses the dialog (header close uses this under the hood).
- Dialog.Action — the confirm button, with native promise support: an async
onClickkeeps the dialog open and undismissable until it resolves, then closes it.
Props
Dialog.Root
| Prop | Type | Default | Notes |
|---|---|---|---|
open |
boolean |
— | Controlled open state |
defaultOpen |
boolean |
false |
Uncontrolled initial state |
onOpenChange |
(open, details) => void |
— | Fires on open / close |
modal |
boolean | "trap-focus" |
true |
Trap focus and lock scroll |
disablePointerDismissal |
boolean |
false |
Keep open on outside press |
Dialog.Content
| Prop | Type | Default | Notes |
|---|---|---|---|
icon |
ReactNode |
— | Decorative icon above the title (aria-hidden, secondary ink, 32px) |
title |
ReactNode |
— | Header heading; sets accessible name |
description |
ReactNode |
— | Supporting text; sets accessible description |
consequences |
ReactNode[] |
— | Outcome bullets at the top of the body, one li per item |
warning |
ReactNode |
— | Final error-colored caution line before body children |
actions |
ReactNode |
— | Footer action row. When set, header close is never shown |
showClose |
boolean |
true |
Header close; ignored when actions is set |
children |
ReactNode |
— | Body above the action row |
className |
string |
— | Applied to the popup (e.g. max-w-md) |
…Popup |
— | — | initialFocus, finalFocus, and other popup props |
Dialog.FullscreenContent
| Prop | Type | Default | Notes |
|---|---|---|---|
icon |
ReactNode |
— | Decorative icon above the title (aria-hidden, secondary ink, 48px) |
title |
ReactNode |
— | Heading of the takeover; sets accessible name |
description |
ReactNode |
— | Supporting text; sets accessible description |
actions |
ReactNode |
— | Action row under the body. When set, corner close is never shown |
showClose |
boolean |
true |
Corner close; ignored when actions is set |
children |
ReactNode |
— | Body between header and actions |
className |
string |
— | Applied to the full-screen popup |
Dialog.Trigger / Dialog.Close
- render —
ReactElement— element to render as (typically aButton), receiving the part’s behavior.
Dialog.Action
- onClick —
(event) => void | Promise<unknown>— return a promise to lock the dialog open (with the buttonloading) until it settles; resolve closes, reject stays open. A non-promise return closes immediately, likeDialog.Close. - All other
Buttonprops (variant,color,size, …) pass through; the defaulteffectsurface fits dialog confirms. - useDialogPending() — hook;
truewhile an action promise is in flight (for disabling secondary buttons).
Accessibility
- Semantics — Base UI renders
role="dialog"andaria-modal.title/descriptionwirearia-labelledby/aria-describedby. If you compose without aTitle, give the popup anaria-label. Passrole="alertdialog"onDialog.Contentwhen the panel is an urgent warning the user has to answer — assistive tech then announces the panel and reads its description on open, so no separate live region is needed. - Focus — opening moves focus into the panel and traps it while
modal; closing returns focus to the trigger. Steer targets withinitialFocus/finalFocuson the popup. - Scroll — page scroll is locked while a modal dialog is open.
- Dismiss — action dialogs dismiss through
actions(wrap cancel inDialog.Close). Info dialogs withoutactionskeep the header close so there is always an explicit control.
Keyboard:
Escape— closes the dialog.Tab/Shift+Tab— cycle focus within the trapped panel.- Outside click closes the dialog (disable with
disablePointerDismissal). - While a
Dialog.Actionpromise is pending,Escapeand outside click are ignored — the dialog closes when the work resolves.
Best practices
- Reach for
Dialogfor focused, interruptible tasks — destructive confirms included. Spell the stakes out withconsequencesandwarningrather than by taking away the exits. - Leave outside click alone on a confirm: dismissing means the action didn’t
happen, so the worst case is reopening. Save
disablePointerDismissalfor panels holding typed input, where the click would destroy real work. - Always give the panel a
title(oraria-label); adddescriptionwhen the purpose isn’t obvious from the title alone. - Put Cancel / Confirm in
actions— never also show the header X. Name the confirming action (Delete,Save changes), never a bareOK. - Wrap dismissive buttons in
Dialog.Close; make the confirm aDialog.Actionwith an asynconClickwhen it must stay open until the work lands — reach for a controlledopenonly when the open state lives elsewhere. - Keep dialogs short. Heavy scrolling or multi-step flows fit a full page or drawer better than a centered modal.
- Prefer
className="max-w-md"on form dialogs; the default max width islg.