Alert Dialog
A question that must be answered before the page can be used again, with the answers side by side under it.
'use client';
import { AlertDialog } from '@/components/panelui/alert-dialog';
import { Button } from '@/components/panelui/button';
export default function AlertDialogOverview() {
return (
<AlertDialog>
<AlertDialog.Trigger render={<Button variant="destructive" />}>Delete project</AlertDialog.Trigger>
<AlertDialog.Content tone="destructive">
<AlertDialog.Title>Delete Orchard?</AlertDialog.Title>
<AlertDialog.Description>
Its 14 boards and their history are deleted with it. This cannot be undone.
</AlertDialog.Description>
<AlertDialog.Footer>
<AlertDialog.Cancel />
<AlertDialog.Action>Delete project</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog>
);
}Use it to ask a question that has to be answered before anything else on the page: deleting a project, discarding changes, signing out of every device.
A press outside it, Escape and AlertDialog.Cancel all close it without doing anything, and AlertDialog.Action closes it once it has done its work. The question sits in a panel, and AlertDialog.Footer moves out of the panel into the darker band under it, where the answers sit side by side at equal widths.
For a task rather than a yes or no, such as a form to fill in, use Dialog, or Sheet for a longer one. For a message that can wait on the page, use Alert.
Installation
npx shadcn@latest add https://panelui.dev/r/web/alert-dialog.jsonpnpm dlx shadcn@latest add https://panelui.dev/r/web/alert-dialog.jsonyarn dlx shadcn@latest add https://panelui.dev/r/web/alert-dialog.jsonbunx shadcn@latest add https://panelui.dev/r/web/alert-dialog.jsonThe source is copied into components/panelui/, where you can edit it.
npm install panelui-webpnpm add panelui-webyarn add panelui-webbun add panelui-webImport from panelui-web/alert-dialog instead of @/components/panelui/alert-dialog. Tailwind needs one line to read the package's classes, which is on the installation page.
Install the packages it uses:
npm install @base-ui/react tailwind-variantspnpm add @base-ui/react tailwind-variantsyarn add @base-ui/react tailwind-variantsbun add @base-ui/react tailwind-variantsAdd the component it is built on: Button.
Copy the source into components/panelui/alert-dialog.tsx:
Usage
import { AlertDialog } from "@/components/panelui/alert-dialog"<AlertDialog>
<AlertDialog.Trigger render={<Button variant="destructive" />}>Delete project</AlertDialog.Trigger>
<AlertDialog.Content tone="destructive">
<AlertDialog.Title>Delete Orchard?</AlertDialog.Title>
<AlertDialog.Description>Its 14 boards go with it.</AlertDialog.Description>
<AlertDialog.Footer>
<AlertDialog.Cancel />
<AlertDialog.Action onClick={remove}>Delete project</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog>Composition
<AlertDialog>
<AlertDialog.Trigger />
<AlertDialog.Content>
<AlertDialog.Media />
<AlertDialog.Title />
<AlertDialog.Description />
<AlertDialog.Footer>
<AlertDialog.Cancel />
<AlertDialog.Action />
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog>AlertDialog.Trigger— What opens the dialog. Pass your own button asrender.AlertDialog.Content— The dialog: a panel in a darker band, over a dimmed page.tonecolours the media and sets the action's default colour,size="sm"centres a shorter question, andblurfrosts the page behind it. Anything other than the footer, such as a field, goes in the panel.AlertDialog.Media— A glyph in a disc of the dialog's tone, above the title.AlertDialog.Title— The question. The dialog is announced by it.AlertDialog.Description— What follows from each answer, read out after the title.AlertDialog.Footer— The answers, side by side at equal widths in the band under the panel. Keep it a direct child ofAlertDialog.Content.AlertDialog.Cancel— Closes the dialog and does nothing else. It reads "Cancel" unless given children.AlertDialog.Action— Does what the dialog asks about, then closes it. It takes a Button's props, atone, andvariant="soft"for an answer that is not the main one. WhenonClickreturns a promise, it waits for it.holdmakes it an action that must be pressed and held.
Examples
With an icon
AlertDialog.Media puts a glyph in a disc of the dialog's tone above the title. size="sm" makes the dialog narrower and centres the question over the two answers.
'use client';
import { LogOutIcon } from 'lucide-react';
import { AlertDialog } from '@/components/panelui/alert-dialog';
import { Button } from '@/components/panelui/button';
export default function AlertDialogWithMedia() {
return (
<AlertDialog>
<AlertDialog.Trigger render={<Button variant="outline" />}>Sign out everywhere</AlertDialog.Trigger>
<AlertDialog.Content tone="warning" size="sm">
<AlertDialog.Media>
<LogOutIcon />
</AlertDialog.Media>
<AlertDialog.Title>Sign out everywhere?</AlertDialog.Title>
<AlertDialog.Description>You will be signed out on 4 devices, this one included.</AlertDialog.Description>
<AlertDialog.Footer>
<AlertDialog.Cancel />
<AlertDialog.Action>Sign out</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog>
);
}Type to confirm
Anything that is not the footer goes in the panel, here a field. The action stays disabled until the repository's name is typed, so a habit of pressing Enter cannot delete it. dismissible={false} keeps a press outside from throwing the typing away.
'use client';
import { AlertDialog } from '@/components/panelui/alert-dialog';
import { Button } from '@/components/panelui/button';
import { Input } from '@/components/panelui/input';
import { Label } from '@/components/panelui/label';
import { useState } from 'react';
const NAME = 'harbour-api';
export default function AlertDialogTypeToConfirm() {
const [typed, setTyped] = useState('');
return (
<AlertDialog dismissible={false} onOpenChange={(open) => !open && setTyped('')}>
<AlertDialog.Trigger render={<Button variant="destructive" />}>Delete repository</AlertDialog.Trigger>
<AlertDialog.Content tone="destructive">
<AlertDialog.Title>Delete {NAME}?</AlertDialog.Title>
<AlertDialog.Description>Its 1,284 commits, issues and releases go with it.</AlertDialog.Description>
<div className="mt-2 flex flex-col gap-2">
<Label htmlFor="confirm-name">
<Label.Text>
Type <span className="font-mono">{NAME}</span> to confirm
</Label.Text>
</Label>
<Input
id="confirm-name"
autoComplete="off"
spellCheck={false}
className="font-mono"
value={typed}
onChange={(event) => setTyped(event.target.value)}
/>
</div>
<AlertDialog.Footer>
<AlertDialog.Cancel />
<AlertDialog.Action disabled={typed !== NAME}>Delete</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog>
);
}While it works
The action's onClick returns a promise. While it runs, the button shows a spinner and the dialog cannot be closed. It closes when the promise resolves. When it rejects, the dialog stays open, and here a plain Alert in the panel says what went wrong. Turn on the switch to see the failure.
'use client';
import { Alert } from '@/components/panelui/alert';
import { AlertDialog } from '@/components/panelui/alert-dialog';
import { Button } from '@/components/panelui/button';
import { Label } from '@/components/panelui/label';
import { Switch } from '@/components/panelui/switch';
import { useState } from 'react';
export default function AlertDialogAsync() {
const [fail, setFail] = useState(false);
const [failed, setFailed] = useState(false);
const [published, setPublished] = useState(false);
const publish = () =>
new Promise<void>((resolve, reject) => {
setFailed(false);
window.setTimeout(() => {
if (fail) {
setFailed(true);
reject(new Error('The release server did not answer.'));
} else {
setPublished(true);
resolve();
}
}, 1400);
});
return (
<div className="flex flex-col items-center gap-4">
<AlertDialog onOpenChange={(open) => open && setFailed(false)}>
<AlertDialog.Trigger render={<Button />}>Publish 2.4.0</AlertDialog.Trigger>
<AlertDialog.Content>
<AlertDialog.Title>Publish version 2.4.0?</AlertDialog.Title>
<AlertDialog.Description>It goes out to 18,400 installs over the next hour.</AlertDialog.Description>
<Alert tone="destructive" variant="plain" open={failed} className="mt-1">
<Alert.Icon />
<Alert.Description>The release server did not answer. Nothing was published.</Alert.Description>
</Alert>
<AlertDialog.Footer>
<AlertDialog.Cancel />
<AlertDialog.Action onClick={publish}>{failed ? 'Try again' : 'Publish'}</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog>
<Label>
<Switch size="sm" checked={fail} onCheckedChange={setFail} />
<Label.Text>Make the next attempt fail</Label.Text>
</Label>
<p className="text-xs text-muted-foreground" aria-live="polite">
{published ? 'Version 2.4.0 is out.' : 'Nothing published yet.'}
</p>
</div>
);
}Hold to confirm
hold={1500} makes the action one that must be pressed and held for a second and a half while a fill runs across it. Letting go early drains it. Holding Space or Enter works the same way.
'use client';
import { Trash2Icon } from 'lucide-react';
import { AlertDialog } from '@/components/panelui/alert-dialog';
import { Button } from '@/components/panelui/button';
export default function AlertDialogHold() {
return (
<AlertDialog>
<AlertDialog.Trigger render={<Button variant="destructive" />}>Erase workspace</AlertDialog.Trigger>
<AlertDialog.Content tone="destructive">
<AlertDialog.Media>
<Trash2Icon />
</AlertDialog.Media>
<AlertDialog.Title>Erase the Northwind workspace?</AlertDialog.Title>
<AlertDialog.Description>
Every project, file and member is removed for good. Press and hold the button until it fills to go ahead.
</AlertDialog.Description>
<AlertDialog.Footer>
<AlertDialog.Cancel />
<AlertDialog.Action hold={1500}>Hold to erase</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog>
);
}Three answers
A footer can hold more than two answers. Discard is an action with tone="destructive" and variant="soft": a tint rather than a fill, so Save stays the one filled button.
'use client';
import { AlertDialog } from '@/components/panelui/alert-dialog';
import { Button } from '@/components/panelui/button';
import { useState } from 'react';
export default function AlertDialogThreeChoices() {
const [last, setLast] = useState<string | null>(null);
return (
<div className="flex flex-col items-center gap-4">
<AlertDialog>
<AlertDialog.Trigger render={<Button variant="outline" />}>Close the roadmap</AlertDialog.Trigger>
<AlertDialog.Content>
<AlertDialog.Title>Save your changes to Q4 roadmap?</AlertDialog.Title>
<AlertDialog.Description>You moved three items and renamed a lane since the last save.</AlertDialog.Description>
<AlertDialog.Footer>
<AlertDialog.Action tone="destructive" variant="soft" onClick={() => setLast('Changes discarded.')}>
Discard
</AlertDialog.Action>
<AlertDialog.Cancel />
<AlertDialog.Action onClick={() => setLast('Changes saved.')}>Save</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog>
<p className="text-xs text-muted-foreground" aria-live="polite">
{last ?? 'Nothing chosen yet.'}
</p>
</div>
);
}API Reference
AlertDialog
| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | — | Whether it is open, when the caller holds that. |
defaultOpen | boolean | false | Whether it starts open, when the caller does not hold it. |
onOpenChange | (open: boolean) => void | — | Called when it opens or closes. |
dismissible | boolean | true | Whether a press outside it closes it, as Cancel would. Escape always does. Default true. |
AlertDialog.Trigger
| Prop | Type | Default | Description |
|---|---|---|---|
render | ReactElement | — | Your own element to be the trigger, such as a styled button. It is given the click handler and the attributes that tie it to the dialog. |
AlertDialog.Content
| Prop | Type | Default | Description |
|---|---|---|---|
tone | AlertDialogTone | 'neutral' | The theme role it is drawn in: the colour of AlertDialog.Media, and of an action that is not given its own. destructive makes the action red. Default neutral. |
size | 'default' | 'sm' | 'default' | sm is narrower, with the question centred over the answers. Default default. |
blur | boolean | false | Frost the page behind the dialog instead of only dimming it. |
inline | boolean | false | Draw it in the page instead of over it: no backdrop, no trigger, always there. For previews; open is ignored. |
AlertDialog.Action
| Prop | Type | Default | Description |
|---|---|---|---|
onClick | (event: MouseEvent<HTMLButtonElement>) => void | Promise<unknown> | — | What it does. When it returns a promise, the button shows a spinner and the dialog stays open, and cannot be closed, until it settles: it closes when the promise resolves and stays open when it rejects. Call event.preventDefault() to keep the dialog open after a plain handler. |
tone | 'primary' | 'destructive' | — | Its colour. Default: destructive in a destructive dialog, primary otherwise. |
variant | 'solid' | 'soft' | 'solid' | solid is filled; soft is a tint of its colour, for an answer that is not the main one. Default solid. |
hold | number | — | Makes it an action that must be pressed and held, for this many milliseconds, while a fill runs across it. Letting go early drains the fill. Space and Enter can be held the same way. |
Every part also takes the props of the element it renders, and a className.
Notes
Closing
A press outside it, Escape and AlertDialog.Cancel close it, unless an action is running. dismissible={false} stops a press outside from closing it, for a question that a stray click should not answer; Escape still does. AlertDialog.Action closes it after its onClick, unless that handler calls event.preventDefault() or returns a promise that rejects.
Focus
When it opens, focus moves to the first thing in it that takes focus: a field in the panel if there is one, otherwise the first answer. Put AlertDialog.Cancel first in the footer, so the answer focused by default is the one that changes nothing. Focus goes back to the trigger when it closes.
Holding and screen readers
A screen reader cannot hold a button, so a press that arrives with no pointer or key going down first, as a screen reader's does, confirms straight away. The button is described as "Press and hold to confirm" for everyone else.
Opening it from code
Pass open and onOpenChange to open it from anywhere, such as a menu item, without an AlertDialog.Trigger.
Server Components
AlertDialog is a client component. In a file that starts with "use client", write its parts with a dot, as on this page. A Server Component cannot read AlertDialog.Trigger off the import, so each part is also exported by name: AlertDialogTrigger is AlertDialog.Trigger, and so on for the others.
import { AlertDialog, AlertDialogTrigger, AlertDialogContent, AlertDialogMedia, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction } from "@/components/panelui/alert-dialog"