Dialog
betaA window over the page, with a backdrop, for a choice or a short task that must be done or cancelled before going on.
Preview
confirm
quiet action
form
size
long text
Install
1. Add the registry to
components.json, once per app. Without it, the shadcn CLI looks up@txbrendanin a public list that txbrendan/ui does not control.npx shadcn@latest registry add "@txbrendan=https://txbrendan.dev/r/{name}.json"2. Add the theme, once per app. It replaces your color tokens with ones checked for contrast.
npx shadcn@latest add @txbrendan/theme3. Add the component.
npx shadcn@latest add @txbrendan/dialog
Use it for
- A choice people must make before they go on, like confirming a delete.
- A short form that needs full attention, like editing a profile.
- Always give it a DialogTitle. It names the dialog for screen readers.
- Put the actions in DialogFooter, with the safe choice before the main action, which goes last.
- An extra quiet action, like Discard, goes first as a Button with emphasis="ghost". It sits at the start of the row, away from the main action.
- The dialog opens at the end of the page body. In a box with its own theme, like a dark preview on a light page, pass that box as container on DialogPopup so the dialog and its backdrop keep the box's colors.
Avoid it for
- Extra details or a quick edit beside a button. Use a popover, which does not block the page.
- A message that needs no answer. Use an alert on the page.
- Long forms or many steps. Use a page.
- Opening a dialog when the page loads. People did not ask for it.
- More than three actions, or long labels, in DialogFooter. It stays on one row, so on a phone the dialog would scroll sideways.
- closeButton={false} when the footer starts with Discard or Delete and no field comes before it. Focus would start on that button, so Enter would run it.
Props
- Dialog
Root. Holds the open state and renders no element. Takes open, defaultOpen, onOpenChange, and disablePointerDismissal.
{ open?: boolean; defaultOpen?: boolean; onOpenChange?: (open: boolean, details) => void; disablePointerDismissal?: boolean }
- open
Dialog. Whether it is open, controlled. Use it with onOpenChange.
boolean
- defaultOpen
Dialog. Whether it is open at the start, when you do not control it.
boolean · default false
- onOpenChange
Dialog. Called with the new open state when it opens or closes.
(open: boolean, details) => void
- disablePointerDismissal
Dialog. Keeps a press outside from closing it. Esc and the Close button still close it.
boolean · default false
- DialogTrigger
Part. The button that opens it. Pass render={<Button />} to use your own button.
ComponentProps<"button"> & { render?: ReactElement }
- DialogPopup
Part. The dialog, centered over a backdrop. It renders in a portal while open.
ComponentProps<"div">
- size
DialogPopup. Most width: 24rem, 32rem, or 42rem. It never gets wider than the screen.
"sm" | "md" | "lg" · default "md"
- closeButton
DialogPopup. Shows a Close button with an X in the top corner. Turn it off only when the footer has a Cancel button.
boolean · default true
- DialogTitle
Part. The dialog's name. An <h2> by default.
ComponentProps<"h2">
- DialogDescription
Part. Muted text under the title. Screen readers read it when the dialog opens.
ComponentProps<"p">
- DialogFooter
Part. One row of actions at the bottom. Ghost buttons sit at the start, and the rest line up at the end.
Omit<ComponentProps<"div">, "color">
- DialogClose
Part. A button that closes the dialog, like Cancel.
ComponentProps<"button"> & { render?: ReactElement }
- initialFocus
DialogPopup. Where focus goes when it opens. By default, the Close button, or the first control inside when there is none.
RefObject<HTMLElement | null> | boolean
- container
DialogPopup. The element the dialog and its backdrop open in. By default, the end of the page body, so no parent can clip them. Pass an element, or a ref itself (not ref.current), to open them inside, for example to keep a parent's theme. Only client code has elements and refs. A null container shows no dialog, but still locks page scroll until it closes.
HTMLElement | ShadowRoot | RefObject<HTMLElement | ShadowRoot | null> | null
- effect
DialogPopup. How the dialog and its backdrop open and close. rise fades in and rises a little. grow fades in and grows in place, and closes the same way. Left out, it follows an effect-<name> class on <body>, where popups open, and rises with none.
"rise" | "grow" · default "rise"
- className
DialogPopup. Extra classes. Later classes win, so className='max-w-xl' replaces the width.
string
| Prop | Type | Default | What it does |
|---|---|---|---|
| Dialog | { open?: boolean; defaultOpen?: boolean; onOpenChange?: (open: boolean, details) => void; disablePointerDismissal?: boolean } | - | Root. Holds the open state and renders no element. Takes open, defaultOpen, onOpenChange, and disablePointerDismissal. |
| open | boolean | - | Dialog. Whether it is open, controlled. Use it with onOpenChange. |
| defaultOpen | boolean | false | Dialog. Whether it is open at the start, when you do not control it. |
| onOpenChange | (open: boolean, details) => void | - | Dialog. Called with the new open state when it opens or closes. |
| disablePointerDismissal | boolean | false | Dialog. Keeps a press outside from closing it. Esc and the Close button still close it. |
| DialogTrigger | ComponentProps<"button"> & { render?: ReactElement } | - | Part. The button that opens it. Pass render={<Button />} to use your own button. |
| DialogPopup | ComponentProps<"div"> | - | Part. The dialog, centered over a backdrop. It renders in a portal while open. |
| size | "sm" | "md" | "lg" | "md" | DialogPopup. Most width: 24rem, 32rem, or 42rem. It never gets wider than the screen. |
| closeButton | boolean | true | DialogPopup. Shows a Close button with an X in the top corner. Turn it off only when the footer has a Cancel button. |
| DialogTitle | ComponentProps<"h2"> | - | Part. The dialog's name. An <h2> by default. |
| DialogDescription | ComponentProps<"p"> | - | Part. Muted text under the title. Screen readers read it when the dialog opens. |
| DialogFooter | Omit<ComponentProps<"div">, "color"> | - | Part. One row of actions at the bottom. Ghost buttons sit at the start, and the rest line up at the end. |
| DialogClose | ComponentProps<"button"> & { render?: ReactElement } | - | Part. A button that closes the dialog, like Cancel. |
| initialFocus | RefObject<HTMLElement | null> | boolean | - | DialogPopup. Where focus goes when it opens. By default, the Close button, or the first control inside when there is none. |
| container | HTMLElement | ShadowRoot | RefObject<HTMLElement | ShadowRoot | null> | null | - | DialogPopup. The element the dialog and its backdrop open in. By default, the end of the page body, so no parent can clip them. Pass an element, or a ref itself (not ref.current), to open them inside, for example to keep a parent's theme. Only client code has elements and refs. A null container shows no dialog, but still locks page scroll until it closes. |
| effect | "rise" | "grow" | "rise" | DialogPopup. How the dialog and its backdrop open and close. rise fades in and rises a little. grow fades in and grows in place, and closes the same way. Left out, it follows an effect-<name> class on <body>, where popups open, and rises with none. |
| className | string | - | DialogPopup. Extra classes. Later classes win, so className='max-w-xl' replaces the width. |
Accessibility
- The popup has role="dialog". DialogTitle names it, and DialogDescription describes it.
- While it is open, the rest of the page is hidden from screen readers with aria-hidden.
- Opening it moves focus to the Close button in the corner, which is first in the tab order. With closeButton={false}, focus goes to the first control inside.
- Tab stays inside until it closes, even when pressed fast. Then focus goes back to the trigger.
- Escape closes it, and so does a click on the backdrop. Set disablePointerDismissal on Dialog to ignore backdrop clicks.
- The page behind cannot be scrolled or reached while it is open.
- The Close button in the corner is named Close. Touch screen reader users cannot press Escape, so keep it, or keep a Cancel button.
- The theme tests hold dialog text and muted text to 4.5:1 on the popover color, and focus rings and input borders to 3:1, in light and dark.
Examples
Confirm a delete (needs @txbrendan/button)
"use client";
import { Button } from "@/components/ui/button";
function DeleteProject({ onDelete }: { onDelete: () => void }) {
return (
<Dialog>
<DialogTrigger render={<Button tone="danger" emphasis="soft" />}>Delete project</DialogTrigger>
<DialogPopup size="sm" closeButton={false}>
<DialogTitle>Delete this project?</DialogTitle>
<DialogDescription>This deletes every file in it. You cannot undo it.</DialogDescription>
<DialogFooter>
<DialogClose render={<Button emphasis="outline" />}>Cancel</DialogClose>
<DialogClose render={<Button tone="danger" />} onClick={onDelete}>
Delete project
</DialogClose>
</DialogFooter>
</DialogPopup>
</Dialog>
);
}A form that closes when it saves (needs @txbrendan/button, input, and label)
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
function EditProfile({ name, save }: { name: string; save: (data: FormData) => Promise<void> }) {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button emphasis="outline" />}>Edit profile</DialogTrigger>
<DialogPopup>
<DialogTitle>Edit profile</DialogTitle>
<DialogDescription>Your name shows on every comment you leave.</DialogDescription>
<form
action={async (data) => {
await save(data);
setOpen(false);
}}
>
<div className="mt-6 flex flex-col gap-1.5">
<Label htmlFor="profile-name">Name</Label>
<Input id="profile-name" name="name" defaultValue={name} />
</div>
<DialogFooter>
<DialogClose render={<Button emphasis="outline" />}>Cancel</DialogClose>
<Button type="submit" tone="brand">
Save
</Button>
</DialogFooter>
</form>
</DialogPopup>
</Dialog>
);
}Wider, for a table or a long form (needs @txbrendan/button)
import { Button } from "@/components/ui/button";
<Dialog>
<DialogTrigger render={<Button emphasis="outline" />}>Import contacts</DialogTrigger>
<DialogPopup size="lg">
<DialogTitle>Import contacts</DialogTitle>
<DialogDescription>Check each row before you import it.</DialogDescription>
</DialogPopup>
</Dialog>Grows in place (needs @txbrendan/button)
import { Button } from "@/components/ui/button";
<Dialog>
<DialogTrigger render={<Button emphasis="outline" />}>Rename</DialogTrigger>
<DialogPopup effect="grow">
<DialogTitle>Rename file</DialogTitle>
<DialogDescription>The new name shows for everyone with access.</DialogDescription>
</DialogPopup>
</Dialog>