Alert Dialog
betaA window over the page that asks people to confirm or cancel something serious, like a delete, before anything happens.
Preview
delete
leave
confirm
size
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/alert-dialog
Use it for
- Confirming an action that cannot be undone, like deleting a project or leaving with unsaved changes.
- Ask a question in AlertDialogTitle, and say what will happen in AlertDialogDescription.
- Put Cancel first in AlertDialogFooter. It gets focus when the dialog opens, so Enter never confirms by accident.
- Keep the footer to one row: quiet actions lead, and the confirm trails. Use short labels, so two or three buttons fit a 320px screen.
- Name the main button with the action, like 'Delete project', not 'OK' or 'Yes'. Give it tone='danger' when it destroys data.
- For an action that takes time, control open, keep the dialog open while it runs, and close it when it is done.
- The alert 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 AlertDialogPopup so the alert dialog and its backdrop keep the box's colors.
Avoid it for
- A form or a task with several fields. Use a dialog.
- A message that needs no answer. Use Alert on the page, or Toast.
- Actions people can undo. Do it at once, and offer Undo for 8 seconds with toast.undoable().
- Opening one when the page loads. People did not ask for it.
Props
- AlertDialog
Root. Holds the open state and renders no element. Takes open, defaultOpen, and onOpenChange.
{ open?: boolean; defaultOpen?: boolean; onOpenChange?: (open: boolean, details) => void }
- open
AlertDialog. Whether it is open, controlled. Use it with onOpenChange.
boolean
- defaultOpen
AlertDialog. Whether it is open at the start, when you do not control it.
boolean · default false
- onOpenChange
AlertDialog. Called with the new open state when it opens or closes.
(open: boolean, details) => void
- AlertDialogTrigger
Part. The button that opens it. Pass render={<Button />} to use your own button. It keeps data-slot="alert-dialog-trigger", even from a server component.
ComponentProps<"button"> & { render?: ReactElement }
- AlertDialogPopup
Part. The alert dialog, centered over a backdrop. It renders in a portal while open. It has no Close button in the corner.
ComponentProps<"div">
- size
AlertDialogPopup. Most width: 24rem, 28rem, or 32rem. It never gets wider than the screen.
"sm" | "md" | "lg" · default "md"
- AlertDialogTitle
Part. The question it asks, like 'Delete this project?'. An <h2> by default.
ComponentProps<"h2">
- AlertDialogDescription
Part. What will happen. Screen readers read it when it opens.
ComponentProps<"p">
- AlertDialogFooter
Part. One row of actions at the bottom, lined up at the end. It never wraps, so the confirm always sits last.
Omit<ComponentProps<"div">, "color">
- AlertDialogClose
Part. A button that closes it, like Cancel, or the main action when it finishes at once.
ComponentProps<"button"> & { render?: ReactElement }
- initialFocus
AlertDialogPopup. Where focus goes when it opens. By default, the first button, which should be Cancel.
RefObject<HTMLElement | null> | boolean
- container
AlertDialogPopup. The element the alert 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 alert dialog, but still locks page scroll until it closes.
HTMLElement | ShadowRoot | RefObject<HTMLElement | ShadowRoot | null> | null
- effect
AlertDialogPopup. How the alert 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
AlertDialogPopup. Extra classes. Later classes win, so className='max-w-xl' replaces the width.
string
| Prop | Type | Default | What it does |
|---|---|---|---|
| AlertDialog | { open?: boolean; defaultOpen?: boolean; onOpenChange?: (open: boolean, details) => void } | - | Root. Holds the open state and renders no element. Takes open, defaultOpen, and onOpenChange. |
| open | boolean | - | AlertDialog. Whether it is open, controlled. Use it with onOpenChange. |
| defaultOpen | boolean | false | AlertDialog. Whether it is open at the start, when you do not control it. |
| onOpenChange | (open: boolean, details) => void | - | AlertDialog. Called with the new open state when it opens or closes. |
| AlertDialogTrigger | ComponentProps<"button"> & { render?: ReactElement } | - | Part. The button that opens it. Pass render={<Button />} to use your own button. It keeps data-slot="alert-dialog-trigger", even from a server component. |
| AlertDialogPopup | ComponentProps<"div"> | - | Part. The alert dialog, centered over a backdrop. It renders in a portal while open. It has no Close button in the corner. |
| size | "sm" | "md" | "lg" | "md" | AlertDialogPopup. Most width: 24rem, 28rem, or 32rem. It never gets wider than the screen. |
| AlertDialogTitle | ComponentProps<"h2"> | - | Part. The question it asks, like 'Delete this project?'. An <h2> by default. |
| AlertDialogDescription | ComponentProps<"p"> | - | Part. What will happen. Screen readers read it when it opens. |
| AlertDialogFooter | Omit<ComponentProps<"div">, "color"> | - | Part. One row of actions at the bottom, lined up at the end. It never wraps, so the confirm always sits last. |
| AlertDialogClose | ComponentProps<"button"> & { render?: ReactElement } | - | Part. A button that closes it, like Cancel, or the main action when it finishes at once. |
| initialFocus | RefObject<HTMLElement | null> | boolean | - | AlertDialogPopup. Where focus goes when it opens. By default, the first button, which should be Cancel. |
| container | HTMLElement | ShadowRoot | RefObject<HTMLElement | ShadowRoot | null> | null | - | AlertDialogPopup. The element the alert 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 alert dialog, but still locks page scroll until it closes. |
| effect | "rise" | "grow" | "rise" | AlertDialogPopup. How the alert 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 | - | AlertDialogPopup. Extra classes. Later classes win, so className='max-w-xl' replaces the width. |
Accessibility
- The popup has role="alertdialog". AlertDialogTitle names it, and AlertDialogDescription describes it, so screen readers read both when it opens.
- While it is open, the rest of the page is hidden from screen readers with aria-hidden and cannot be scrolled or reached.
- Opening it moves focus to the first button. Put Cancel first, so the safe choice has focus.
- Tab stays inside until it closes, even when pressed fast. Then focus goes back to the trigger.
- Escape closes it, as Cancel does. A click on the backdrop does not, so people answer on purpose.
- There is no Close button in the corner. Keep a Cancel button, since touch screen reader users cannot press Escape.
- The theme tests hold dialog text and muted text to 4.5:1 on the popover color, 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 (
<AlertDialog>
<AlertDialogTrigger render={<Button tone="danger" emphasis="soft" />}>Delete project</AlertDialogTrigger>
<AlertDialogPopup>
<AlertDialogTitle>Delete this project?</AlertDialogTitle>
<AlertDialogDescription>This deletes 24 files and 3 deploys. You cannot undo it.</AlertDialogDescription>
<AlertDialogFooter>
<AlertDialogClose render={<Button emphasis="outline" />}>Cancel</AlertDialogClose>
<AlertDialogClose render={<Button tone="danger" />} onClick={onDelete}>
Delete project
</AlertDialogClose>
</AlertDialogFooter>
</AlertDialogPopup>
</AlertDialog>
);
}Wait for the action, then close (needs @txbrendan/button)
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
function RemoveMember({ name, remove }: { name: string; remove: () => Promise<void> }) {
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
return (
<AlertDialog open={open} onOpenChange={(next) => !loading && setOpen(next)}>
<AlertDialogTrigger render={<Button emphasis="outline" />}>Remove {name}</AlertDialogTrigger>
<AlertDialogPopup size="sm">
<AlertDialogTitle>Remove {name} from the team?</AlertDialogTitle>
<AlertDialogDescription>They lose access to every project at once.</AlertDialogDescription>
<AlertDialogFooter>
<AlertDialogClose render={<Button emphasis="outline" disabled={loading} />}>Cancel</AlertDialogClose>
<Button
tone="danger"
loading={loading}
onClick={async () => {
setLoading(true);
await remove();
setLoading(false);
setOpen(false);
}}
>
Remove
</Button>
</AlertDialogFooter>
</AlertDialogPopup>
</AlertDialog>
);
}Leave without saving (needs @txbrendan/button)
import { Button } from "@/components/ui/button";
<AlertDialog>
<AlertDialogTrigger render={<Button emphasis="ghost" />}>Back to projects</AlertDialogTrigger>
<AlertDialogPopup>
<AlertDialogTitle>Leave without saving?</AlertDialogTitle>
<AlertDialogDescription>Your changes to this page will be lost.</AlertDialogDescription>
<AlertDialogFooter>
<AlertDialogClose render={<Button emphasis="outline" />}>Keep editing</AlertDialogClose>
<AlertDialogClose render={<Button tone="danger" />}>Leave</AlertDialogClose>
</AlertDialogFooter>
</AlertDialogPopup>
</AlertDialog>Grows in place (needs @txbrendan/button)
import { Button } from "@/components/ui/button";
<AlertDialog>
<AlertDialogTrigger render={<Button emphasis="outline" />}>Discard draft</AlertDialogTrigger>
<AlertDialogPopup effect="grow">
<AlertDialogTitle>Discard this draft?</AlertDialogTitle>
<AlertDialogDescription>You cannot undo it.</AlertDialogDescription>
<AlertDialogFooter>
<AlertDialogClose render={<Button emphasis="outline" />}>Cancel</AlertDialogClose>
<AlertDialogClose render={<Button tone="danger" />}>Discard</AlertDialogClose>
</AlertDialogFooter>
</AlertDialogPopup>
</AlertDialog>