Dialog

A modal overlay for forms, details, and focused tasks. Backdrop clicks, Escape, and an explicit close all dismiss it by default.

Default

import { FileTextIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Dialog } from "@/components/ui/dialog"

export default function Example() {
  return (
    <Dialog.Root>
      <Dialog.Trigger
        render={
          <Button variant="outline">
            <FileTextIcon />
            Mission Brief
          </Button>
        }
      />
      <Dialog.Portal>
        <Dialog.Backdrop />
        <Dialog.Viewport>
          <Dialog.Popup>
            <Dialog.CloseButton />
            <Dialog.Header>
              <Dialog.Title>Mission brief</Dialog.Title>
              <Dialog.Description>
                Orbit insertion burn starts at 14:22 UTC. Review the objectives
                before you leave the hangar.
              </Dialog.Description>
            </Dialog.Header>
            <Dialog.Body>
              <dl className="grid gap-3 text-sm">
                <div className="flex justify-between gap-4">
                  <dt className="text-muted-foreground">Window</dt>
                  <dd className="font-medium text-foreground">14:22-14:40 UTC</dd>
                </div>
                <div className="flex justify-between gap-4">
                  <dt className="text-muted-foreground">Altitude</dt>
                  <dd className="font-medium text-foreground">410 km</dd>
                </div>
                <div className="flex justify-between gap-4">
                  <dt className="text-muted-foreground">Inclination</dt>
                  <dd className="font-medium text-foreground">51.6°</dd>
                </div>
                <div className="flex justify-between gap-4">
                  <dt className="text-muted-foreground">Crew</dt>
                  <dd className="font-medium text-foreground">3 on station</dd>
                </div>
              </dl>
            </Dialog.Body>
            <Dialog.Footer>
              <Dialog.Close
                render={<Button variant="secondary">Got it</Button>}
              />
            </Dialog.Footer>
          </Dialog.Popup>
        </Dialog.Viewport>
      </Dialog.Portal>
    </Dialog.Root>
  )
}

Anatomy

import { Dialog } from "@/components/ui/dialog"

<Dialog.Root>
  <Dialog.Trigger />
  <Dialog.Portal>
    <Dialog.Backdrop />
    <Dialog.Viewport>
      <Dialog.Popup>
        <Dialog.CloseButton />
        <Dialog.Header>
          <Dialog.Title />
          <Dialog.Description />
        </Dialog.Header>
        <Dialog.Body />
        <Dialog.Footer>
          <Dialog.Close />
        </Dialog.Footer>
      </Dialog.Popup>
    </Dialog.Viewport>
  </Dialog.Portal>
</Dialog.Root>

Form

Wrap Body and Footer in a form, make Save type="submit", and close with controlled open. Point initialFocus at the field that matters most.

import * as React from "react"
import { UserIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Dialog } from "@/components/ui/dialog"

export default function Example() {
  const [open, setOpen] = React.useState(false)
  const callsignRef = React.useRef<HTMLInputElement>(null)

  return (
    <Dialog.Root open={open} onOpenChange={setOpen}>
      <Dialog.Trigger
        render={
          <Button variant="outline">
            <UserIcon />
            Edit Crew Profile
          </Button>
        }
      />
      <Dialog.Portal>
        <Dialog.Backdrop />
        <Dialog.Viewport>
          <Dialog.Popup initialFocus={callsignRef}>
            <Dialog.CloseButton />
            <Dialog.Header>
              <Dialog.Title>Edit crew profile</Dialog.Title>
              <Dialog.Description>
                Update the callsign and role shown on the roster before the next
                briefing.
              </Dialog.Description>
            </Dialog.Header>
            <form
              className="flex min-h-0 flex-1 flex-col"
              onSubmit={(event) => {
                event.preventDefault()
                setOpen(false)
              }}
            >
              <Dialog.Body className="flex flex-col gap-4">
                <label className="flex flex-col gap-1.5 text-sm">
                  <span className="font-medium text-foreground">Callsign</span>
                  <input
                    ref={callsignRef}
                    name="callsign"
                    defaultValue="Comet"
                    className="h-8 w-full rounded-full border border-border bg-muted/faint px-3 text-sm outline-none"
                  />
                </label>
                <label className="flex flex-col gap-1.5 text-sm">
                  <span className="font-medium text-foreground">Role</span>
                  <input
                    name="role"
                    defaultValue="Flight Commander"
                    className="h-8 w-full rounded-full border border-border bg-muted/faint px-3 text-sm outline-none"
                  />
                </label>
                <label className="flex flex-col gap-1.5 text-sm">
                  <span className="font-medium text-foreground">Notes</span>
                  <textarea
                    name="notes"
                    rows={3}
                    defaultValue="Prefers night-side docking approaches."
                    className="min-h-24 w-full resize-y rounded-2xl border border-border bg-muted/faint px-3 py-2 text-sm outline-none"
                  />
                </label>
              </Dialog.Body>
              <Dialog.Footer>
                <Dialog.Close
                  render={<Button variant="secondary">Cancel</Button>}
                />
                <Button type="submit">Save</Button>
              </Dialog.Footer>
            </form>
          </Dialog.Popup>
        </Dialog.Viewport>
      </Dialog.Portal>
    </Dialog.Root>
  )
}

Scrollable body

The default keeps the popup on screen with max-h-full so Body scrolls while the header and footer stay pinned.

import { ClipboardListIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Dialog } from "@/components/ui/dialog"

const checklistItems = [
  "Confirm propellant pressure within launch band",
  "Verify range safety uplink and abort tones",
  "Seal crew hatch and check cabin differential",
  // ...more items
]

export default function Example() {
  return (
    <Dialog.Root>
      <Dialog.Trigger
        render={
          <Button variant="outline">
            <ClipboardListIcon />
            Pre-Launch Checklist
          </Button>
        }
      />
      <Dialog.Portal>
        <Dialog.Backdrop />
        <Dialog.Viewport>
          <Dialog.Popup>
            <Dialog.CloseButton />
            <Dialog.Header>
              <Dialog.Title>Pre-launch checklist</Dialog.Title>
              <Dialog.Description>
                Work through each station call before you arm the auto-sequence.
                The list scrolls while the header and footer stay put.
              </Dialog.Description>
            </Dialog.Header>
            <Dialog.Body>
              <ol className="list-decimal space-y-3 pl-5 text-sm text-foreground">
                {checklistItems.map((item) => (
                  <li key={item}>{item}</li>
                ))}
              </ol>
            </Dialog.Body>
            <Dialog.Footer>
              <Dialog.Close
                render={<Button variant="secondary">Close</Button>}
              />
              <Dialog.Close render={<Button>Mark Complete</Button>} />
            </Dialog.Footer>
          </Dialog.Popup>
        </Dialog.Viewport>
      </Dialog.Portal>
    </Dialog.Root>
  )
}

Long content

Pass className="max-h-none" so the whole dialog scrolls inside the viewport. Combine it with a wider max-w-* when the content needs more room.

import { ScrollTextIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Dialog } from "@/components/ui/dialog"

const missionLogParagraphs = [
  "T-00:42:18 Pad cameras show vapor venting from the LOX feedline as expected.",
  "T-00:31:05 Navigation computers finished the final ephemeris load.",
  // ...more paragraphs
]

export default function Example() {
  return (
    <Dialog.Root>
      <Dialog.Trigger
        render={
          <Button variant="outline">
            <ScrollTextIcon />
            Mission Log
          </Button>
        }
      />
      <Dialog.Portal>
        <Dialog.Backdrop />
        <Dialog.Viewport>
          <Dialog.Popup className="max-h-none max-w-2xl">
            <Dialog.CloseButton />
            <Dialog.Header>
              <Dialog.Title>Mission log</Dialog.Title>
              <Dialog.Description>
                Full countdown transcript for Flight 47. Scroll the page-like
                dialog to review every milestone through orbital insertion.
              </Dialog.Description>
            </Dialog.Header>
            <Dialog.Body className="space-y-4">
              {missionLogParagraphs.map((paragraph) => (
                <p key={paragraph} className="text-sm text-foreground">
                  {paragraph}
                </p>
              ))}
            </Dialog.Body>
            <Dialog.Footer>
              <Dialog.Close
                render={<Button variant="secondary">Close</Button>}
              />
            </Dialog.Footer>
          </Dialog.Popup>
        </Dialog.Viewport>
      </Dialog.Portal>
    </Dialog.Root>
  )
}

Nested

Opening a dialog from another shrinks and dims the parent through Base UI's nested attributes. Keep nesting to one level.

import { BellIcon, SettingsIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Dialog } from "@/components/ui/dialog"

export default function Example() {
  return (
    <Dialog.Root>
      <Dialog.Trigger
        render={
          <Button variant="outline">
            <SettingsIcon />
            Crew Settings
          </Button>
        }
      />
      <Dialog.Portal>
        <Dialog.Backdrop />
        <Dialog.Viewport>
          <Dialog.Popup>
            <Dialog.CloseButton />
            <Dialog.Header>
              <Dialog.Title>Crew settings</Dialog.Title>
              <Dialog.Description>
                Manage how the station surfaces alerts and shift reminders.
                Opening preferences nests a second dialog on top.
              </Dialog.Description>
            </Dialog.Header>
            <Dialog.Body>
              <div className="flex items-center justify-between gap-4 rounded-2xl border border-border px-4 py-3">
                <div className="min-w-0">
                  <p className="text-sm font-medium text-foreground">
                    Notification preferences
                  </p>
                  <p className="text-sm text-muted-foreground">
                    Choose which channels reach the crew during flight.
                  </p>
                </div>
                <Dialog.Root>
                  <Dialog.Trigger
                    render={
                      <Button variant="outline" size="sm">
                        <BellIcon />
                        Open
                      </Button>
                    }
                  />
                  <Dialog.Portal>
                    <Dialog.Backdrop />
                    <Dialog.Viewport>
                      <Dialog.Popup>
                        <Dialog.CloseButton />
                        <Dialog.Header>
                          <Dialog.Title>Notification preferences</Dialog.Title>
                          <Dialog.Description>
                            Toggle the channels that wake the duty officer during
                            a hold.
                          </Dialog.Description>
                        </Dialog.Header>
                        <Dialog.Body>{/* preference rows */}</Dialog.Body>
                        <Dialog.Footer>
                          <Dialog.Close
                            render={<Button variant="secondary">Cancel</Button>}
                          />
                          <Dialog.Close render={<Button>Save</Button>} />
                        </Dialog.Footer>
                      </Dialog.Popup>
                    </Dialog.Viewport>
                  </Dialog.Portal>
                </Dialog.Root>
              </div>
            </Dialog.Body>
            <Dialog.Footer>
              <Dialog.Close
                render={<Button variant="secondary">Close</Button>}
              />
            </Dialog.Footer>
          </Dialog.Popup>
        </Dialog.Viewport>
      </Dialog.Portal>
    </Dialog.Root>
  )
}

Close confirmation

Intercept onOpenChange(false) while a draft exists, then open an AlertDialog. That covers the X button, Escape, and backdrop clicks in one place.

import * as React from "react"
import { PencilIcon } from "lucide-react"
import { AlertDialog } from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
import { Dialog } from "@/components/ui/dialog"

export default function Example() {
  const [dialogOpen, setDialogOpen] = React.useState(false)
  const [confirmationOpen, setConfirmationOpen] = React.useState(false)
  const [draft, setDraft] = React.useState("")

  return (
    <Dialog.Root
      open={dialogOpen}
      onOpenChange={(open) => {
        if (!open && draft.trim()) {
          setConfirmationOpen(true)
        } else {
          if (!open) {
            setDraft("")
          }
          setDialogOpen(open)
        }
      }}
    >
      <Dialog.Trigger
        render={
          <Button variant="outline">
            <PencilIcon />
            New Log Entry
          </Button>
        }
      />
      <Dialog.Portal>
        <Dialog.Backdrop />
        <Dialog.Viewport>
          <Dialog.Popup>
            <Dialog.CloseButton />
            <Dialog.Header>
              <Dialog.Title>New log entry</Dialog.Title>
              <Dialog.Description>
                Draft a note for the flight log. Closing with unsaved text asks
                you to confirm first.
              </Dialog.Description>
            </Dialog.Header>
            <form
              className="flex min-h-0 flex-1 flex-col"
              onSubmit={(event) => {
                event.preventDefault()
                setDraft("")
                setDialogOpen(false)
              }}
            >
              <Dialog.Body>
                <label className="flex flex-col gap-1.5 text-sm">
                  <span className="font-medium text-foreground">Entry</span>
                  <textarea
                    name="entry"
                    rows={5}
                    required
                    value={draft}
                    onChange={(event) => setDraft(event.target.value)}
                    placeholder="What happened on station?"
                    className="min-h-24 w-full resize-y rounded-2xl border border-border bg-muted/faint px-3 py-2 text-sm outline-none"
                  />
                </label>
              </Dialog.Body>
              <Dialog.Footer>
                <Dialog.Close
                  render={<Button variant="secondary">Cancel</Button>}
                />
                <Button type="submit">Post Entry</Button>
              </Dialog.Footer>
            </form>
          </Dialog.Popup>
        </Dialog.Viewport>
      </Dialog.Portal>

      <AlertDialog.Root
        open={confirmationOpen}
        onOpenChange={setConfirmationOpen}
      >
        <AlertDialog.Portal>
          <AlertDialog.Popup>
            <AlertDialog.Header>
              <AlertDialog.Title>Discard entry?</AlertDialog.Title>
              <AlertDialog.Description>
                Your draft will be lost if you leave without posting.
              </AlertDialog.Description>
            </AlertDialog.Header>
            <AlertDialog.Footer>
              <AlertDialog.Cancel>Keep Editing</AlertDialog.Cancel>
              <AlertDialog.Action
                onClick={() => {
                  setConfirmationOpen(false)
                  setDraft("")
                  setDialogOpen(false)
                }}
              >
                Discard
              </AlertDialog.Action>
            </AlertDialog.Footer>
          </AlertDialog.Popup>
        </AlertDialog.Portal>
      </AlertDialog.Root>
    </Dialog.Root>
  )
}

Open from a menu

A Menu.Item onClick can open a controlled dialog that lives beside the menu rather than inside it.

import * as React from "react"
import { EyeIcon, PencilIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Dialog } from "@/components/ui/dialog"
import { Menu } from "@/components/ui/menu"

export default function Example() {
  const [dialogOpen, setDialogOpen] = React.useState(false)

  return (
    <>
      <Menu.Root>
        <Menu.Trigger render={<Button variant="outline">Track</Button>} />
        <Menu.Portal>
          <Menu.Positioner align="start">
            <Menu.Popup>
              <Menu.Item>
                <PencilIcon />
                Rename track
              </Menu.Item>
              <Menu.Item onClick={() => setDialogOpen(true)}>
                <EyeIcon />
                View details
              </Menu.Item>
              <Menu.Separator />
              <Menu.Item variant="danger">Delete track</Menu.Item>
            </Menu.Popup>
          </Menu.Positioner>
        </Menu.Portal>
      </Menu.Root>

      <Dialog.Root open={dialogOpen} onOpenChange={setDialogOpen}>
        <Dialog.Portal>
          <Dialog.Backdrop />
          <Dialog.Viewport>
            <Dialog.Popup>
              <Dialog.CloseButton />
              <Dialog.Header>
                <Dialog.Title>Track details</Dialog.Title>
                <Dialog.Description>
                  Night Beats · 24 songs · last updated this orbit.
                </Dialog.Description>
              </Dialog.Header>
              <Dialog.Body>{/* track metadata */}</Dialog.Body>
              <Dialog.Footer>
                <Dialog.Close
                  render={<Button variant="secondary">Close</Button>}
                />
              </Dialog.Footer>
            </Dialog.Popup>
          </Dialog.Viewport>
        </Dialog.Portal>
      </Dialog.Root>
    </>
  )
}

Multiple triggers with payload

Several triggers can share one handle and dialog, each passing its own payload. A function child reads it back so the dialog can render the matching content.

Mira Chen

Flight Commander

Jonah Reyes

Systems Engineer

Asha Okonkwo

Mission Specialist

import { UsersIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Dialog } from "@/components/ui/dialog"

type CrewMember = {
  name: string
  role: string
  callsign: string
  status: string
}

// One handle and one dialog. Each trigger passes its own payload so the
// popup can render the right crew member.
const handle = Dialog.createHandle<CrewMember>()

const crewRoster: CrewMember[] = [
  {
    name: "Mira Chen",
    role: "Flight Commander",
    callsign: "Comet",
    status: "On station",
  },
  {
    name: "Jonah Reyes",
    role: "Systems Engineer",
    callsign: "Relay",
    status: "EVA prep",
  },
  {
    name: "Asha Okonkwo",
    role: "Mission Specialist",
    callsign: "Orbit",
    status: "Rest cycle",
  },
]

export default function Example() {
  return (
    <>
      {crewRoster.map((member) => (
        <Dialog.Trigger
          key={member.callsign}
          handle={handle}
          payload={member}
          render={
            <Button variant="outline" size="sm">
              <UsersIcon />
              View
            </Button>
          }
        />
      ))}
      <Dialog.Root<CrewMember> handle={handle}>
        {({ payload }) => (
          <Dialog.Portal>
            <Dialog.Backdrop />
            <Dialog.Viewport>
              <Dialog.Popup>
                <Dialog.CloseButton />
                <Dialog.Header>
                  <Dialog.Title>{payload?.name}</Dialog.Title>
                  <Dialog.Description>
                    Roster card for callsign {payload?.callsign}.
                  </Dialog.Description>
                </Dialog.Header>
                <Dialog.Body>
                  <dl className="grid gap-3 text-sm">
                    <div className="flex justify-between gap-4">
                      <dt className="text-muted-foreground">Role</dt>
                      <dd className="font-medium text-foreground">
                        {payload?.role}
                      </dd>
                    </div>
                    <div className="flex justify-between gap-4">
                      <dt className="text-muted-foreground">Callsign</dt>
                      <dd className="font-medium text-foreground">
                        {payload?.callsign}
                      </dd>
                    </div>
                    <div className="flex justify-between gap-4">
                      <dt className="text-muted-foreground">Status</dt>
                      <dd className="font-medium text-foreground">
                        {payload?.status}
                      </dd>
                    </div>
                  </dl>
                </Dialog.Body>
                <Dialog.Footer>
                  <Dialog.Close
                    render={<Button variant="secondary">Close</Button>}
                  />
                </Dialog.Footer>
              </Dialog.Popup>
            </Dialog.Viewport>
          </Dialog.Portal>
        )}
      </Dialog.Root>
    </>
  )
}

Usage Guidelines

  • Dialog vs. Alert Dialog vs. Popover or Menu Use Dialog for forms, details, and focused tasks the user can dismiss freely. Use Alert Dialog when the choice must be deliberate and backdrop clicks should not dismiss. Prefer a Popover or Menu for lightweight actions that stay anchored to a trigger.
  • Always include a visible close Ship CloseButton or a labeled Close in the footer so dismiss is discoverable without relying on Escape or the backdrop alone.
  • Keep forms short Limit dialogs to a few fields and give buttons specific labels such as Save or Post Entry instead of generic OK.
  • Required title and description Always include Dialog.Title and Dialog.Description. These wire automatically to aria-labelledby and aria-describedbyso assistive technologies announce the dialog's purpose.
  • Limit nesting Nest at most one dialog inside another. Deeper stacks are hard to follow and fight the shrink and dim treatment on the parent.

Props

Dialog.Root

PropTypeDefault
openboolean—
defaultOpenbooleanfalse
onOpenChange(open: boolean, eventDetails) => void—
modalboolean | 'trap-focus'true
disablePointerDismissalbooleanfalse
handleDialogHandle<Payload>—

Dialog.Trigger

PropTypeDefault
handleDialogHandle<Payload>—
payloadPayload—
idstring—

Dialog.Popup

PropTypeDefault
initialFocusboolean | RefObject | (openType) => boolean | HTMLElement | null | void—
finalFocusboolean | RefObject | (closeType) => boolean | HTMLElement | null | void—

Dialog.CloseButton

A ghost icon Button that closes the dialog. Accepts all Button props. Override aria-label and children for i18n.

PropTypeDefault
aria-labelstring"Close"
childrenReact.ReactNode<XIcon />

Dialog.Close

An unstyled close primitive. Compose it with render, for example <Dialog.Close render={<Button variant="secondary">Cancel</Button>} />.

This covers the parts used above. See the Base UI Dialog docs for actionsRef, event details, CSS variables and data attributes.