Pho 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. Actionsactions footer 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.)

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

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

Dialog.Action

Accessibility

Keyboard:

Best practices