Dialog
A window overlaid on the page that focuses attention on one task and blocks everything else until it is finished or dismissed.
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
export default function DialogDemo() {
return (
<Dialog>
<DialogTrigger render={<Button variant="outline" />}>
Edit profile
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit profile</DialogTitle>
<DialogDescription>
Changes are visible to everyone in your workspace.
</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field>
<FieldLabel htmlFor="profile-name">Name</FieldLabel>
<Input id="profile-name" defaultValue="Ada Lovelace" />
</Field>
<Field>
<FieldLabel htmlFor="profile-username">Username</FieldLabel>
<Input id="profile-username" defaultValue="ada" />
</Field>
</FieldGroup>
<DialogFooter>
<DialogClose render={<Button variant="outline" />}>
Cancel
</DialogClose>
<Button>Save changes</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}Installation
$ pnpm dlx shadcn@latest add https://www.prfct.dev/r/dialog.jsonUsage
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"<Dialog>
<DialogTrigger render={<Button variant="outline" />}>Edit profile</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit profile</DialogTitle>
<DialogDescription>Changes are visible to everyone in your workspace.</DialogDescription>
</DialogHeader>
{/* … */}
<DialogFooter>
<DialogClose render={<Button variant="outline" />}>Cancel</DialogClose>
<Button>Save changes</Button>
</DialogFooter>
</DialogContent>
</Dialog>DialogContent renders the portal, the scrim and the popup for you. Triggers and close buttons take a render prop, so the element that opens the dialog is a real prfct Button rather than a wrapper around one.
Examples
Form
The most common dialog: a short form that edits one thing. Focus moves to the first field when the dialog opens and returns to the trigger when it closes. Keep it to a handful of fields — anything longer deserves a page or a Sheet. Wrap the fields and the footer in one <form> so Enter submits.
"use client"
import * as React from "react"
import { PlusIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
InputGroupText,
} from "@/components/ui/input-group"
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"
// Accents become plain letters (é → e, ł → l) instead of disappearing.
const slugify = (value: string) =>
value
.normalize("NFD")
.replace(/\p{Diacritic}/gu, "")
.replace(/ł/gi, "l")
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "")
export default function DialogForm() {
const [open, setOpen] = React.useState(false)
const [name, setName] = React.useState("")
return (
<Dialog
open={open}
onOpenChange={(next) => {
setOpen(next)
if (!next) setName("")
}}
>
<DialogTrigger render={<Button />}>
<PlusIcon data-icon="inline-start" />
New project
</DialogTrigger>
<DialogContent>
<form
className="flex flex-col gap-5"
onSubmit={(event) => {
event.preventDefault()
setOpen(false)
}}
>
<DialogHeader>
<DialogTitle>Create project</DialogTitle>
<DialogDescription>
Projects group issues, deployments and docs for one product.
</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field>
<FieldLabel htmlFor="project-name">Name</FieldLabel>
<Input
id="project-name"
placeholder="Atlas"
required
value={name}
onChange={(event) => setName(event.target.value)}
/>
</Field>
<Field>
<FieldLabel htmlFor="project-url">URL</FieldLabel>
<InputGroup>
<InputGroupAddon>
<InputGroupText>prfct.dev/</InputGroupText>
</InputGroupAddon>
<InputGroupInput
id="project-url"
readOnly
value={slugify(name) || "atlas"}
className="text-muted-foreground"
/>
</InputGroup>
<FieldDescription>
Generated from the name. You can change it later.
</FieldDescription>
</Field>
<Field>
<FieldLabel htmlFor="project-template">Template</FieldLabel>
<NativeSelect
id="project-template"
defaultValue="web"
className="w-full"
>
<NativeSelectOption value="blank">Blank</NativeSelectOption>
<NativeSelectOption value="web">
Web application
</NativeSelectOption>
<NativeSelectOption value="docs">
Documentation site
</NativeSelectOption>
<NativeSelectOption value="api">API service</NativeSelectOption>
</NativeSelect>
</Field>
</FieldGroup>
<DialogFooter>
<DialogClose render={<Button variant="outline" />}>
Cancel
</DialogClose>
<Button type="submit">Create project</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}Scrollable content
When the content is taller than the viewport, scroll only the body so the title and the actions stay visible. The dialog itself never grows beyond the viewport minus a 1rem margin on each side.
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
const sections = [
{
title: "1. Your account",
body: "You are responsible for the activity on your account and for keeping your credentials secure. Tell us immediately if you notice unauthorized access.",
},
{
title: "2. Acceptable use",
body: "Don't use the service to break the law, infringe on others' rights, distribute malware, or interfere with the service's normal operation.",
},
{
title: "3. Your content",
body: "You own what you create. You grant us a limited license to host and display it only as needed to provide the service to you.",
},
{
title: "4. Billing",
body: "Paid plans renew automatically at the end of each billing period. You can cancel at any time; cancellation takes effect at the end of the current period.",
},
{
title: "5. Availability",
body: "We aim for 99.95% monthly uptime and publish incidents on our status page. Scheduled maintenance is announced at least 48 hours in advance.",
},
{
title: "6. Termination",
body: "You can close your account at any time. We may suspend accounts that violate these terms, and will tell you why unless the law prevents it.",
},
{
title: "7. Changes",
body: "We'll notify you at least 30 days before material changes take effect. Continuing to use the service after that means you accept the new terms.",
},
]
export default function DialogScrollable() {
return (
<Dialog>
<DialogTrigger render={<Button variant="outline" />}>
Review terms
</DialogTrigger>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle>Terms of service</DialogTitle>
<DialogDescription>Last updated September 1, 2026.</DialogDescription>
</DialogHeader>
{/* Only the body scrolls; the title and actions stay in view. */}
<div className="-mx-6 max-h-[min(22rem,50dvh)] scrollbar-thin overflow-y-auto border-y px-6 py-5">
<div className="flex flex-col gap-5">
{sections.map((section) => (
<section key={section.title} className="flex flex-col gap-1.5">
<h3 className="text-sm font-semibold text-foreground">
{section.title}
</h3>
<p className="text-sm leading-[1.375rem] text-muted-foreground">
{section.body}
</p>
</section>
))}
</div>
</div>
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<DialogClose render={<Button variant="outline" />}>
Decline
</DialogClose>
<DialogClose render={<Button />}>Accept</DialogClose>
</div>
</DialogContent>
</Dialog>
)
}Controlled
Dialogs opened from a menu item, a keyboard shortcut or after an async step have no trigger of their own. Render the dialog outside the menu and control it with open and onOpenChange.
"use client"
import * as React from "react"
import { MoreHorizontalIcon, PencilIcon, Trash2Icon } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Field, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
export default function DialogControlled() {
const [open, setOpen] = React.useState(false)
const [name, setName] = React.useState("Q3 planning")
const [draft, setDraft] = React.useState(name)
function rename(event: React.FormEvent) {
event.preventDefault()
setName(draft.trim() || name)
setOpen(false)
}
return (
<div className="flex items-center gap-3 rounded-xl border bg-card py-2 pr-2 pl-4 shadow-raised">
<span className="text-sm font-medium">{name}</span>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="ghost"
size="icon-sm"
aria-label="Document actions"
/>
}
>
<MoreHorizontalIcon />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuGroup>
<DropdownMenuItem
onClick={() => {
setDraft(name)
setOpen(true)
}}
>
<PencilIcon />
Rename…
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem variant="destructive">
<Trash2Icon />
Delete
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
{/* Opened from a menu item, so it has no trigger of its own: control it. */}
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-md">
<form onSubmit={rename} className="flex flex-col gap-5">
<DialogHeader>
<DialogTitle>Rename document</DialogTitle>
<DialogDescription>
The new name is visible to everyone with access.
</DialogDescription>
</DialogHeader>
<Field>
<FieldLabel htmlFor="document-name">Name</FieldLabel>
<Input
id="document-name"
value={draft}
onChange={(event) => setDraft(event.target.value)}
/>
</Field>
<DialogFooter>
<DialogClose render={<Button variant="outline" />}>
Cancel
</DialogClose>
<Button type="submit">Rename</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
)
}const [open, setOpen] = React.useState(false)
<DropdownMenuItem onClick={() => setOpen(true)}>Rename…</DropdownMenuItem>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>{/* … */}</DialogContent>
</Dialog>Footer
DialogFooter is a tinted strip anchored to the bottom edge. Put actions on the right, secondary context on the left, and use showCloseButton when a dialog only needs a way out.
"use client"
import * as React from "react"
import { CheckIcon, CopyIcon, LinkIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Field, FieldLabel } from "@/components/ui/field"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@/components/ui/input-group"
const url = "https://prfct.dev/share/7f3a-q3-roadmap"
export default function DialogShare() {
const [copied, setCopied] = React.useState(false)
React.useEffect(() => {
if (!copied) return
const timer = setTimeout(() => setCopied(false), 1600)
return () => clearTimeout(timer)
}, [copied])
async function copy() {
try {
await navigator.clipboard.writeText(url)
setCopied(true)
} catch {
// The clipboard can be denied; the link stays in the field to select.
}
}
return (
<Dialog>
<DialogTrigger render={<Button variant="outline" />}>
<LinkIcon data-icon="inline-start" />
Share
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Share link</DialogTitle>
<DialogDescription>
Anyone with the link can view this roadmap.
</DialogDescription>
</DialogHeader>
<Field>
<FieldLabel htmlFor="share-link" className="sr-only">
Link
</FieldLabel>
<InputGroup>
<InputGroupInput id="share-link" defaultValue={url} readOnly />
<InputGroupAddon align="inline-end">
<InputGroupButton
size="icon-xs"
aria-label={copied ? "Copied" : "Copy link"}
onClick={copy}
>
{copied ? (
<CheckIcon className="text-success-11" />
) : (
<CopyIcon />
)}
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
</Field>
<DialogFooter showCloseButton className="sm:justify-between">
<span className="self-center text-xs text-muted-foreground">
Link expires in 30 days
</span>
</DialogFooter>
</DialogContent>
</Dialog>
)
}Width
The default maximum width is max-w-lg (32rem), right for a form. Widen it with a max-w-* class for reference content such as a shortcut list, and keep the side margin: the dialog is always calc(100% - 2rem) wide at most.
import { KeyboardIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Kbd, KbdGroup } from "@/components/ui/kbd"
const groups = [
{
title: "Navigation",
shortcuts: [
{ label: "Open command palette", keys: ["⌘", "K"] },
{ label: "Go to inbox", keys: ["G", "I"] },
{ label: "Go to projects", keys: ["G", "P"] },
{ label: "Search", keys: ["/"] },
],
},
{
title: "Editing",
shortcuts: [
{ label: "Create issue", keys: ["C"] },
{ label: "Assign to me", keys: ["I"] },
{ label: "Change status", keys: ["S"] },
{ label: "Toggle theme", keys: ["D"] },
],
},
]
export default function DialogWide() {
return (
<Dialog>
<DialogTrigger render={<Button variant="ghost" />}>
<KeyboardIcon data-icon="inline-start" />
Shortcuts
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Keyboard shortcuts</DialogTitle>
<DialogDescription>
Press <Kbd>?</Kbd> anywhere to open this list.
</DialogDescription>
</DialogHeader>
<div className="grid gap-8 sm:grid-cols-2">
{groups.map((group) => (
<section key={group.title} className="flex flex-col gap-2">
<h3 className="text-overline text-muted-foreground">
{group.title}
</h3>
<ul className="flex flex-col">
{group.shortcuts.map((shortcut) => (
<li
key={shortcut.label}
className="flex items-center justify-between gap-4 border-b py-2 text-sm last:border-0"
>
<span className="text-foreground">{shortcut.label}</span>
<KbdGroup>
{shortcut.keys.map((key) => (
<Kbd key={key}>{key}</Kbd>
))}
</KbdGroup>
</li>
))}
</ul>
</section>
))}
</div>
</DialogContent>
</Dialog>
)
}Guidelines
When to use
- To collect a small amount of input without leaving the page: rename, invite, edit a few fields.
- To show information that must be acknowledged before continuing.
- To confirm a task started from the current page, with enough context to decide.
When not to use
- To confirm a destructive or irreversible action — use an Alert Dialog, which can't be dismissed by clicking outside.
- For long forms, multi-step flows or anything people need to compare against the page — use a Sheet or a full page.
- For a success message after an action — use a Toast. Interrupting people to say that things went fine is a tax on every task.
- For contextual controls tied to one element — use a Popover.
Choosing an overlay
Every overlay takes some of the page away to focus on one task — the heavier the overlay, the more it hides. Pick the lightest one that does the job.
Writing
Title the dialog with what it does, not with a question you then repeat: Edit profile, Invite teammates. Name the primary button with the outcome, and repeat the key noun if it helps: Save changes, Send 3 invites. The description is optional but usually worth one sentence of context.
One at a time
Never stack a dialog on top of another dialog. If a task needs a second step, replace the content of the first, or move the whole flow to a page.
Accessibility
The dialog follows the WAI-ARIA dialog pattern. Base UI renders the popup with role="dialog", labels it with DialogTitle and describes it with DialogDescription. It doesn't set aria-modal: while a modal dialog is open, everything outside it is marked aria-hidden instead.
| Key | Behavior |
|---|---|
EnterSpace | On the trigger: opens the dialog and moves focus inside it. |
Tab | Moves focus to the next focusable element. Focus is trapped inside the dialog. |
ShiftTab | Moves focus to the previous focusable element, wrapping at the start. |
Esc | Closes the dialog and returns focus to the element that opened it. |
- Always include a title.
DialogTitlegives the dialog its accessible name. If the design has no visible title, keep it and addclassName="sr-only". - Focus. Focus moves to the first focusable element on open — or to the popup itself when opened by touch, so the keyboard doesn't jump up. Change it with
initialFocusandfinalFocusonDialogContent. - Everything else is inert. While a modal dialog is open, page scroll is locked and content outside can't be reached with the pointer, keyboard or screen reader.
- A way out. Keep the close button (
showCloseButton, on by default) or aDialogClosebutton in the footer. Touch screen reader users can't press Esc. - Motion. The dialog fades and scales in over 320ms with the
entereasing and leaves faster than it arrived. With reduced motion, it appears instantly.
API reference
Dialog
The root. Doesn't render an element. Accepts every prop of Base UI Dialog.Root.
DialogTrigger
Opens the dialog. Renders a <button>; use render to render a prfct Button.
DialogContent
Renders the portal, scrim and popup. The two wrappers it composes, DialogPortal and DialogOverlay, are also exported for layouts that place them by hand.
DialogHeader, DialogTitle, DialogDescription
DialogHeader stacks the title and description and reserves room for the close button. DialogTitle renders an <h2> and names the dialog; DialogDescription renders a <p> and describes it.
DialogFooter
A tinted strip, flush with the dialog's bottom edge. Stacks actions on small screens, right-aligns them from sm up.
DialogClose
Closes the dialog. Renders a <button>; use render to render a prfct Button.