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
CloseButtonor a labeledClosein 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.TitleandDialog.Description. These wire automatically toaria-labelledbyandaria-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
| Prop | Type | Default |
|---|---|---|
| open | boolean | — |
| defaultOpen | boolean | false |
| onOpenChange | (open: boolean, eventDetails) => void | — |
| modal | boolean | 'trap-focus' | true |
| disablePointerDismissal | boolean | false |
| handle | DialogHandle<Payload> | — |
Dialog.Trigger
| Prop | Type | Default |
|---|---|---|
| handle | DialogHandle<Payload> | — |
| payload | Payload | — |
| id | string | — |
Dialog.Popup
| Prop | Type | Default |
|---|---|---|
| initialFocus | boolean | RefObject | (openType) => boolean | HTMLElement | null | void | — |
| finalFocus | boolean | 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.
| Prop | Type | Default |
|---|---|---|
| aria-label | string | "Close" |
| children | React.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.