Photon Design System

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:

  1. Header — an optional icon above title / description, and optionally a header close
  2. Body — an optional consequences list and warning line, then children (forms, copy, errors)
  3. Actions — actions footer row, right-aligned, with an optional caption at its start
<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.

PatternactionsHeader close
Confirm / form with buttonssethidden
Info / read-only panelomittedshown
Info, but you want no X eitheromittedshowClose={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>

Caption

A limit, a cost or a policy that frames the save without being the question goes in caption: one short sentence in description ink at text-xs, at the action row’s start with the buttons at its end. It keeps the description about the fields. When the row runs short of room, the caption takes a line of its own above the buttons.

<Dialog.Content
  title="Change the handle"
  description="The project's links follow its handle."
  caption="You may change it twice a month."
  className="max-w-md"
  actions={
    <>
      <Dialog.Close render={<Button variant="ghost">Cancel</Button>} />
      <Dialog.Close render={<Button>Save</Button>} />
    </>
  }
>
  <Input label="Handle" value={handle} onChange={…} />
</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.)

State: closed
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.

State: closed
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 render to reuse a Button.
  • 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 Popup sibling, 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 Popup sibling, 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 onClick keeps the dialog open and undismissable until it resolves, then closes it.

Props

Dialog.Root

PropTypeDefaultNotes
openboolean—Controlled open state
defaultOpenbooleanfalseUncontrolled initial state
onOpenChange(open, details) => void—Fires on open / close
modalboolean | "trap-focus"trueTrap focus and lock scroll
disablePointerDismissalbooleanfalseKeep open on outside press

Dialog.Content

PropTypeDefaultNotes
iconReactNode—Decorative icon above the title (aria-hidden, secondary ink, 32px)
titleReactNode—Header heading; sets accessible name
descriptionReactNode—Supporting text; sets accessible description
consequencesReactNode[]—Outcome bullets at the top of the body, one li per item
warningReactNode—Final error-colored caution line before body children
actionsReactNode—Footer action row. When set, header close is never shown
captionReactNode—Quiet footnote at the action row’s start (limit, cost, policy)
showClosebooleantrueHeader close; ignored when actions is set
childrenReactNode—Body above the action row
classNamestring—Applied to the popup (e.g. max-w-md)
…Popup——initialFocus, finalFocus, and other popup props

Dialog.FullscreenContent

PropTypeDefaultNotes
iconReactNode—Decorative icon above the title (aria-hidden, secondary ink, 48px)
titleReactNode—Heading of the takeover; sets accessible name
descriptionReactNode—Supporting text; sets accessible description
actionsReactNode—Action row under the body. When set, corner close is never shown
showClosebooleantrueCorner close; ignored when actions is set
childrenReactNode—Body between header and actions
classNamestring—Applied to the full-screen popup

Dialog.Trigger / Dialog.Close

  • render — ReactElement — element to render as (typically a Button), receiving the part’s behavior.

Dialog.Action

  • onClick — (event) => void | Promise<unknown> — return a promise to lock the dialog open (with the button loading) until it settles; resolve closes, reject stays open. A non-promise return closes immediately, like Dialog.Close.
  • All other Button props (variant, color, size, …) pass through; the default effect surface fits dialog confirms.
  • useDialogPending() — hook; true while an action promise is in flight (for disabling secondary buttons).

Accessibility

  • Semantics — Base UI renders role="dialog" and aria-modal. title / description wire aria-labelledby / aria-describedby. If you compose without a Title, give the popup an aria-label. Pass role="alertdialog" on Dialog.Content when 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 with initialFocus / finalFocus on the popup.
  • Scroll — page scroll is locked while a modal dialog is open.
  • Dismiss — action dialogs dismiss through actions (wrap cancel in Dialog.Close). Info dialogs without actions keep 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.Action promise is pending, Escape and outside click are ignored — the dialog closes when the work resolves.

Best practices

  • Reach for Dialog for focused, interruptible tasks — destructive confirms included. Spell the stakes out with consequences and warning rather 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 disablePointerDismissal for panels holding typed input, where the click would destroy real work.
  • Always give the panel a title (or aria-label); add description when 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 bare OK.
  • Wrap dismissive buttons in Dialog.Close; make the confirm a Dialog.Action with an async onClick when it must stay open until the work lands — reach for a controlled open only 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 is lg.