Alert dialog
An interrupting dialog that asks for a decision before continuing.
- Status
- Experimental
- Level
- Organism
- Category
- Overlays
- Adoption
- Not used yet
import { AlertDialog } from "@oration/canon/components/alert-dialog";packages/canon/src/components/alert-dialog.tsximport { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger,} from "@oration/canon/components/alert-dialog";import { Button } from "@oration/canon/components/button";import { toast } from "@oration/canon/components/toast";import { SendIcon } from "lucide-react";import * as React from "react";export function Hero() { const [open, setOpen] = React.useState(false); return ( <AlertDialog open={open} onOpenChange={setOpen}> <AlertDialogTrigger render={<Button type="button" variant="outline" />} > <SendIcon data-icon="inline-start" aria-hidden="true" /> Release payment run </AlertDialogTrigger> <AlertDialogContent> <AlertDialogHeader> <AlertDialogTitle>Release payment run?</AlertDialogTitle> <AlertDialogDescription> 212 invoices to 48 suppliers leave today at 2:00 PM CT. Once the bank accepts the file, payments can't be recalled. </AlertDialogDescription> </AlertDialogHeader> <AlertDialogFooter> <AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogAction onClick={() => { setOpen(false); toast.add({ type: "success", title: "Payment run released", description: "$1,284,650.20 leaves today at 2:00 PM CT.", }); }} > Release run </AlertDialogAction> </AlertDialogFooter> </AlertDialogContent> </AlertDialog> );}Usage#
Alert dialog interrupts to ask for a decision before anything else can happen. It is the primitive under Confirm dialog: a surface with the alertdialog role, a centered or left-aligned header, an optional media tile and the same Well Gray footer band as Dialog. It can't be dismissed by clicking the scrim, only by answering or pressing Esc. Nothing in the product imports it directly, and that's the point: reach for Confirm dialog first, and build from these parts only when the decision has a shape Confirm dialog can't express, such as three answers.
When to use
- For a decision with more than two answers, such as Keep editing, Discard and Save and close when leaving an unsaved remittance email.
- When the decision needs a media tile or body content that the ready-made confirmation doesn't lay out, such as an icon beside the title.
- To build a new ready-made confirmation component for
packages/canon, the way Confirm dialog is built.
When not to use
- For a delete, remove, rotate or turn-off with a Cancel and one action. The ready-made version handles focus, pending state and typed confirmation. Use Confirm dialog
- For a task with fields and a Save. Alert dialogs ask, they don't edit. Use Dialog
- To tell someone an action worked. Nothing is being decided. Use Toast
- For a warning that can wait on the page, such as an expiring W-9. Use Alert
The One Filled Button Rule
The Hairline-and-Lift Rule
Anatomy#
Pause autopay?
Halcyon invoices wait for manual approval.
- Scrim.
AlertDialogOverlay, black at 25% (55% in dark), fading in over 200ms. Clicking it does nothing. - Surface.
AlertDialogContent: Popover White, 12px corners, 16px padding, the dialog shadow and ring. 20rem wide below 640px; from 640px, 24rem atsize="default"and still 20rem atsize="sm". - Media.
AlertDialogMedia, an optional 40px Well Gray tile with a 24px icon. At the default size from 640px it sits to the left of the title and description; otherwise it stacks above them. - Header.
AlertDialogHeaderwithAlertDialogTitle(16px, weight 500) andAlertDialogDescription(14px Slate Meta, balanced). Centered on small screens and atsm, left-aligned at the default size from 640px. - Footer band.
AlertDialogFooter: Well Gray at 50% over a hairline, bleeding to the edges. Buttons stack below 640px and right-align above it; atsize="sm"they split the width in two equal columns. - Answers.
AlertDialogCancel(outline, closes) and one or moreAlertDialogActionbuttons, which don't close on their own.
Examples#
Sizes
The default size is 24rem from 640px with a left-aligned header. size="sm" stays 20rem, centers the header and splits the footer into two equal buttons, for a short question with a short answer.
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger,} from "@oration/canon/components/alert-dialog";import { Button } from "@oration/canon/components/button";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function Sizes() { const [open, setOpen] = React.useState<"default" | "sm" | null>(null); return ( <> <AlertDialog open={open === "default"} onOpenChange={(next) => setOpen(next ? "default" : null)} > <AlertDialogTrigger render={<Button type="button" variant="outline" />} > Default </AlertDialogTrigger> <AlertDialogContent> <AlertDialogHeader> <AlertDialogTitle> Resend remittance advice? </AlertDialogTitle> <AlertDialogDescription> Northwind Freight gets the Sep 25 remittance for 3 invoices again at ap@northwindfreight.com. </AlertDialogDescription> </AlertDialogHeader> <AlertDialogFooter> <AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogAction onClick={() => { setOpen(null); toast.add({ title: "Remittance resent to Northwind Freight", }); }} > Resend </AlertDialogAction> </AlertDialogFooter> </AlertDialogContent> </AlertDialog> <AlertDialog open={open === "sm"} onOpenChange={(next) => setOpen(next ? "sm" : null)} > <AlertDialogTrigger render={<Button type="button" variant="outline" />} > Small </AlertDialogTrigger> <AlertDialogContent size="sm"> <AlertDialogHeader> <AlertDialogTitle>Skip this invoice?</AlertDialogTitle> <AlertDialogDescription> INV-20944 moves to next Friday's run. </AlertDialogDescription> </AlertDialogHeader> <AlertDialogFooter> <AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogAction onClick={() => { setOpen(null); toast.add({ title: "INV-20944 skipped", description: "It's in the run for Friday, Oct 9.", }); }} > Skip invoice </AlertDialogAction> </AlertDialogFooter> </AlertDialogContent> </AlertDialog> </> );}With a media tile
AlertDialogMedia puts a 40px icon tile beside the title at the default size and above it at sm or on small screens. Keep the icon decorative.
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogMedia, AlertDialogTitle, AlertDialogTrigger,} from "@oration/canon/components/alert-dialog";import { Button } from "@oration/canon/components/button";import { toast } from "@oration/canon/components/toast";import { CirclePauseIcon } from "lucide-react";import * as React from "react";export function WithMedia() { const [open, setOpen] = React.useState(false); return ( <AlertDialog open={open} onOpenChange={setOpen}> <AlertDialogTrigger render={<Button type="button" variant="outline" />} > <CirclePauseIcon data-icon="inline-start" aria-hidden="true" /> Pause autopay </AlertDialogTrigger> <AlertDialogContent> <AlertDialogHeader> <AlertDialogMedia> <CirclePauseIcon aria-hidden="true" /> </AlertDialogMedia> <AlertDialogTitle> Pause autopay for Halcyon? </AlertDialogTitle> <AlertDialogDescription> New Halcyon invoices wait for manual approval until you turn it back on. The 4 invoices already scheduled still go out Friday. </AlertDialogDescription> </AlertDialogHeader> <AlertDialogFooter> <AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogAction onClick={() => { setOpen(false); toast.add({ title: "Autopay paused for Halcyon", description: "New invoices need approval.", }); }} > Pause autopay </AlertDialogAction> </AlertDialogFooter> </AlertDialogContent> </AlertDialog> );}Three answers
The case Confirm dialog can't express. The destructive answer sits on the left in the red tint, and the safe and saving answers group on the right. Every AlertDialogAction closes the dialog itself.
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger,} from "@oration/canon/components/alert-dialog";import { Button } from "@oration/canon/components/button";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function ThreeAnswers() { const [open, setOpen] = React.useState(false); return ( <AlertDialog open={open} onOpenChange={setOpen}> <AlertDialogTrigger render={<Button type="button" variant="outline" />} > Leave the email editor </AlertDialogTrigger> <AlertDialogContent className="data-[size=default]:sm:max-w-md"> <AlertDialogHeader> <AlertDialogTitle>Leave without saving?</AlertDialogTitle> <AlertDialogDescription> Your edits to the remittance email template haven't been saved. Suppliers keep getting the current version. </AlertDialogDescription> </AlertDialogHeader> <AlertDialogFooter className="sm:justify-between"> <AlertDialogAction variant="destructive" onClick={() => { setOpen(false); toast.add({ title: "Edits discarded" }); }} > Discard </AlertDialogAction> <div className="flex flex-col-reverse gap-2 sm:flex-row"> <AlertDialogCancel>Keep editing</AlertDialogCancel> <AlertDialogAction onClick={() => { setOpen(false); toast.add({ type: "success", title: "Remittance email saved", description: "Used for payments from the next run.", }); }} > Save and close </AlertDialogAction> </div> </AlertDialogFooter> </AlertDialogContent> </AlertDialog> );}States#
| State | Treatment |
|---|---|
| Closed | Nothing is rendered. |
| Opening | Fades in from a 0.96 scale over 200ms on the house ease-out while the scrim fades in. |
| Open | Focus is trapped inside, the page is inert and its scroll locked. Clicks on the scrim are ignored. |
| Closing | Fades toward a 0.98 scale in 140ms, then unmounts. |
| Small | size="sm" keeps the 20rem width at every breakpoint, centers the header and splits the footer into two equal buttons. |
| Reduced motion | Scale is zeroed; only the fades remain. |
Behavior#
- Built on Base UI Alert Dialog. It is always modal: focus is trapped, the page behind is inert, and a click outside doesn't close it. Esc and
AlertDialogCanceldo. AlertDialogActionis a plain Button with adata-slot. It does not close the dialog, so controlopenand set it tofalsein the action's handler once the work is done.AlertDialogCancelis a Base UI Close rendered as an outline Button. Passvariantandsizeto change it.- Opening moves focus to the first focusable element, which is the Cancel button when the footer lists it first. That's the safe default for a decision; move it with
initialFocusonAlertDialogContent. - Closing returns focus to the trigger, or to whatever was focused before it opened.
- Timings come from globals.css, keyed on
data-slot="alert-dialog-content": 200ms in from 0.96 and 140ms out toward 0.98, the same as Dialog.
Choosing an overlay#
Seven components take over the screen. Pick by the question the overlay asks: a yes or no goes to a confirmation, a few fields to a form dialog, details that belong beside a list to a sheet. For a few controls tied to one button, use a popover instead.
| Component | Reach for it when | Shape | Dismissed by |
|---|---|---|---|
| Dialog | A focused task the page waits for: a short form, a review, a setting with its own Save. You compose the header, body and footer. | Centered, 24rem by default, up to 48rem | Esc, a click on the scrim, the close button |
| Confirm dialog | Confirming one consequential action: delete, remove, rotate, move. Add a typed name when the loss is large. | Centered, 24rem, 28rem when typed | Esc or an answer. The scrim doesn't close it |
| Alert dialog (this page) | A decision Confirm dialog can't express, such as three answers or a media tile. You build the parts; Confirm dialog is built on it. | Centered, 20 or 24rem | Esc or an answer. The scrim doesn't close it |
| Form dialog | Creating or editing one record with two to six fields, a submit and a cancel. | Centered, 28rem | Esc, the scrim, Cancel, the close button |
| Stacked dialog | Three to six pages read or completed in order: what's new, connecting a provider. | Centered, 28rem, with a control bar | Esc, the scrim, Done, the close button |
| Sheet | Record details, logs or a longer edit that should keep the list it came from in view. | From an edge, 24rem wide by default | Esc, the scrim, the close button |
| Drawer | Touch-first flows on phones that people expect to swipe away. Not used in the product yet. | From the bottom, up to the viewport minus 6rem | A swipe, Esc, the scrim |
Do and don't#
Leave without saving?
Your edits to the remittance email haven't been saved.
Payment run scheduled
212 invoices go out Friday, Oct 2 at 2:00 PM CT.
Discard your edits?
The remittance email goes back to the saved version.
Discard your edits?
The remittance email goes back to the saved version.
Content#
- Title the dialog with the question, naming the object: Leave without saving?, Pause autopay for Halcyon?
- The description says what happens to what, in one or two sentences. Name counts, amounts and dates.
- Each answer is a verb phrase that makes sense read alone. Avoid Yes, No and OK.
- Use Cancel or Keep editing for the answer that changes nothing, so people can back out without reading the rest.
Accessibility#
- The popup has
role="alertdialog", labelled byAlertDialogTitleand described byAlertDialogDescription. Always render both, so the question is announced with its context. - Focus moves in on open, is trapped while open and returns to the trigger on close.
- Icons inside
AlertDialogMediaare decorative; mark themaria-hidden="true". - Because the scrim doesn't dismiss it, people who can't see the scrim aren't surprised by a close they didn't intend.
- Under reduced motion only opacity animates.
| Keys | Action |
|---|---|
| Tab | Moves between the answers, wrapping at the ends. |
| Enter | Activates the focused answer. |
| Esc | Closes the dialog, the same as Cancel. |
Design tokens#
| Token | Used for |
|---|---|
--popover | Surface fill |
shadow-lg | The dialog shadow |
--foreground | The 1px ring at 10% |
--muted | Footer band at 50% and the media tile |
--border | The hairline over the footer band |
--muted-foreground | AlertDialogDescription |
--radius-xl | 12px corners on the surface |
--radius-md | 8px corners on the media tile |
--ease-out | 200ms in and 140ms out, from globals.css |
bg-black/25 | The scrim, bg-black/55 in dark |
API reference#
AlertDialog
The root. Holds the open state and renders no element.
Other props spread onto Base UI AlertDialog.Root.
| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | No default | Controls whether it is open. Control it whenever an action should close it. |
defaultOpen | boolean | false | Whether it starts open when uncontrolled. |
onOpenChange | (open: boolean, eventDetails) => void | No default | Called when Esc, Cancel or a trigger changes it. |
onOpenChangeComplete | (open: boolean) => void | No default | Called after the open or close animation ends. |
actionsRef | RefObject<{ close: () => void; unmount: () => void }> | No default | Imperative close and unmount. |
AlertDialogTrigger
The button that opens it.
Other props spread onto Base UI AlertDialog.Trigger (<button>).
| Prop | Type | Default | Description |
|---|---|---|---|
render | ReactElement | (props, state) => ReactElement | No default | Render your own button, such as <Button type="button" variant="outline" />. |
AlertDialogContent
The surface, rendered in a portal with the scrim.
Other props spread onto Base UI AlertDialog.Popup.
| Prop | Type | Default | Description |
|---|---|---|---|
size | "default" | "sm" | "default" | sm stays 20rem wide, centers the header and splits the footer into two equal buttons. |
initialFocus | boolean | RefObject<HTMLElement> | (openType) => HTMLElement | boolean | null | void | No default | Where focus goes on open. Defaults to the first focusable element. |
finalFocus | boolean | RefObject<HTMLElement> | (closeType) => HTMLElement | boolean | null | void | No default | Where focus goes on close. Defaults to the trigger. |
className | string | No default | Merged after the defaults. |
AlertDialogHeader
Lays out the media, title and description, centered or left-aligned by size.
Other props spread onto <div>.
No props of its own.
AlertDialogMedia
A 40px Well Gray tile for an icon, beside or above the title.
Other props spread onto <div>.
No props of its own.
AlertDialogTitle
Names the decision. 16px at weight 500.
Other props spread onto Base UI AlertDialog.Title (<h2>).
No props of its own.
AlertDialogDescription
Explains the consequence. Slate Meta, balanced.
Other props spread onto Base UI AlertDialog.Description (<p>).
No props of its own.
AlertDialogAction
An answer. A Button with data-slot; it doesn't close the dialog.
Other props spread onto Button.
| Prop | Type | Default | Description |
|---|---|---|---|
variant | "default" | "outline" | "secondary" | "ghost" | "destructive" | "link" | "default" | Use destructive when the answer removes something. |
AlertDialogCancel
The answer that changes nothing. Closes the dialog.
Other props spread onto Base UI AlertDialog.Close.
| Prop | Type | Default | Description |
|---|---|---|---|
variant | "default" | "outline" | "secondary" | "ghost" | "destructive" | "link" | "outline" | Button variant. |
size | "xs" | "sm" | "default" | "lg" | "icon-xs" | "icon-sm" | "icon" | "icon-lg" | "default" | Button size. |
AlertDialogOverlay
The scrim. Rendered by AlertDialogContent.
Other props spread onto Base UI AlertDialog.Backdrop.
No props of its own.
AlertDialogPortal
Portals to the body. Rendered by AlertDialogContent.
Other props spread onto Base UI AlertDialog.Portal.
No props of its own.
Known gaps#
Where the implementation and the system disagree today. Follow the system, not the gap.
No file in apps/web imports it. Every confirmation goes through Confirm dialog, which is why this page is marked experimental.
The surface has no max-w-[calc(100%-2rem)], unlike Dialog. Both sizes are 20rem below 640px, so on a 320px viewport the surface touches the screen edges.
The media tile uses 8px corners (rounded-md). DESIGN.md gives icon tiles standard 10px corners.
The component's own classes still say duration-100, zoom-in-95 and zoom-out-95; globals.css overrides them through data-slot.
AlertDialogAction doesn't close the dialog, while AlertDialogCancel does. The names don't say so, and it is easy to ship an action that leaves the dialog open.