Skip to content

Dialog

A window overlaid on the page that focuses attention on one task and blocks everything else until it is finished or dismissed.

SourceBase UIRegistry
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.json

Usage

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.

Q3 planning
"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>

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.

ComponentBlocks the pageDismiss on outside clickUse it for
DialogYesYesA focused task: a short form, a decision with context.
Alert DialogYesNoConfirming destructive or irreversible actions.
SheetYesYesLonger forms, details and filters that keep the page in view.
DrawerYesYes, or swipeMobile-first bottom sheets with gestures and snap points.
PopoverNoYesSmall, interactive content anchored to a trigger.
Hover CardNoOn pointer leavePreviews of links for sighted pointer users.
TooltipNoOn pointer leaveA short text label for an icon or control.

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.

Invite teammatesThey’ll get an email to join Acme.
DoThe title names the task; the actions name the outcomes.
Are you sure?Please confirm to continue.
Don’tGeneric titles and OK/Cancel force people to reread everything.

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.

KeyBehavior
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. DialogTitle gives the dialog its accessible name. If the design has no visible title, keep it and add className="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 initialFocus and finalFocus on DialogContent.
  • 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 a DialogClose button in the footer. Touch screen reader users can't press Esc.
  • Motion. The dialog fades and scales in over 320ms with the enter easing 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.

PropTypeDefault
open

Whether the dialog is open. Use with onOpenChange to control it.

booleanNo default
defaultOpen

Whether the dialog is open initially, when uncontrolled.

booleanfalse
onOpenChange

Called when the dialog opens or closes. details.reason tells you why: trigger-press, outside-press, escape-key, close-press…

(open: boolean, details) => voidNo default
onOpenChangeComplete

Called after the enter or exit animation finishes.

(open: boolean) => voidNo default
modal

true locks scroll and blocks outside interaction. "trap-focus" only traps focus. false allows interaction with the page.

boolean | "trap-focus"true
disablePointerDismissal

Prevents closing when clicking outside the dialog.

booleanfalse
actionsRef

Imperative handle to close or unmount the dialog.

RefObject<{ close, unmount }>No default

DialogTrigger

Opens the dialog. Renders a <button>; use render to render a prfct Button.

PropTypeDefault
render

Replaces the rendered element while keeping behavior.

ReactElement | (props, state) => ReactElementNo default
nativeButton

Set to false when render produces a non-button element.

booleantrue

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.

PropTypeDefault
showCloseButton

Renders an icon button in the top-right corner that closes the dialog.

booleantrue
initialFocus

Element to focus when the dialog opens. Defaults to the first tabbable element.

boolean | RefObject | (interaction) => HTMLElement | booleanNo default
finalFocus

Element to focus when the dialog closes. Defaults to the trigger.

boolean | RefObject | (interaction) => HTMLElement | booleanNo default
className

Merged with the popup classes. Override the width with max-w-*.

stringNo default

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.

PropTypeDefault
showCloseButton

Appends an outline Close button.

booleanfalse

DialogClose

Closes the dialog. Renders a <button>; use render to render a prfct Button.