Skip to content

Alert dialog

An interrupting dialog that asks for a decision before continuing.

Category
Overlays
Adoption
Not used yet
import { AlertDialog } from "@oration/canon/components/alert-dialog";
packages/canon/src/components/alert-dialog.tsx
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 { 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 footer holds at most one filled button, the answer the dialog exists to offer. When the answer destroys something, it is the red tint and there is no filled button at all.

The Hairline-and-Lift Rule

The surface takes its edge from the dialog shadow and a 1px ink ring at 10%. The one CSS border is the hairline over the footer band.

Anatomy#

Pause autopay?

Halcyon invoices wait for manual approval.

  1. Scrim. AlertDialogOverlay, black at 25% (55% in dark), fading in over 200ms. Clicking it does nothing.
  2. Surface. AlertDialogContent: Popover White, 12px corners, 16px padding, the dialog shadow and ring. 20rem wide below 640px; from 640px, 24rem at size="default" and still 20rem at size="sm".
  3. 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.
  4. Header. AlertDialogHeader with AlertDialogTitle (16px, weight 500) and AlertDialogDescription (14px Slate Meta, balanced). Centered on small screens and at sm, left-aligned at the default size from 640px.
  5. Footer band. AlertDialogFooter: Well Gray at 50% over a hairline, bleeding to the edges. Buttons stack below 640px and right-align above it; at size="sm" they split the width in two equal columns.
  6. Answers. AlertDialogCancel (outline, closes) and one or more AlertDialogAction buttons, 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#

States
StateTreatment
ClosedNothing is rendered.
OpeningFades in from a 0.96 scale over 200ms on the house ease-out while the scrim fades in.
OpenFocus is trapped inside, the page is inert and its scroll locked. Clicks on the scrim are ignored.
ClosingFades toward a 0.98 scale in 140ms, then unmounts.
Smallsize="sm" keeps the 20rem width at every breakpoint, centers the header and splits the footer into two equal buttons.
Reduced motionScale 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 AlertDialogCancel do.
  • AlertDialogAction is a plain Button with a data-slot. It does not close the dialog, so control open and set it to false in the action's handler once the work is done.
  • AlertDialogCancel is a Base UI Close rendered as an outline Button. Pass variant and size to 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 initialFocus on AlertDialogContent.
  • 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.

Which overlay to use
ComponentReach for it whenShapeDismissed by
DialogA 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 48remEsc, a click on the scrim, the close button
Confirm dialogConfirming one consequential action: delete, remove, rotate, move. Add a typed name when the loss is large.Centered, 24rem, 28rem when typedEsc 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 24remEsc or an answer. The scrim doesn't close it
Form dialogCreating or editing one record with two to six fields, a submit and a cancel.Centered, 28remEsc, the scrim, Cancel, the close button
Stacked dialogThree to six pages read or completed in order: what's new, connecting a provider.Centered, 28rem, with a control barEsc, the scrim, Done, the close button
SheetRecord details, logs or a longer edit that should keep the list it came from in view.From an edge, 24rem wide by defaultEsc, the scrim, the close button
DrawerTouch-first flows on phones that people expect to swipe away. Not used in the product yet.From the bottom, up to the viewport minus 6remA swipe, Esc, the scrim

Do and don't#

Leave without saving?

Your edits to the remittance email haven't been saved.

Do. Use an alert dialog when the next step depends on the answer, and offer answers that name what happens: Keep editing, Discard, Save and close.

Payment run scheduled

212 invoices go out Friday, Oct 2 at 2:00 PM CT.

Don't. Use one to announce a result with a single OK. Nothing is being decided, so it is an interruption with no purpose; confirm with a toast.

Discard your edits?

The remittance email goes back to the saved version.

Do. Keep the destructive answer in the red tint and make Cancel the first focus.

Discard your edits?

The remittance email goes back to the saved version.

Don't. Fill the destructive answer in indigo so it looks like the safe default.

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 by AlertDialogTitle and described by AlertDialogDescription. 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 AlertDialogMedia are decorative; mark them aria-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.
Keyboard interactions
KeysAction
TabMoves between the answers, wrapping at the ends.
EnterActivates the focused answer.
EscCloses the dialog, the same as Cancel.

Design tokens#

Design tokens
TokenUsed for
--popoverSurface fill
shadow-lgThe dialog shadow
--foregroundThe 1px ring at 10%
--mutedFooter band at 50% and the media tile
--borderThe hairline over the footer band
--muted-foregroundAlertDialogDescription
--radius-xl12px corners on the surface
--radius-md8px corners on the media tile
--ease-out200ms in and 140ms out, from globals.css
bg-black/25The 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.

Props of AlertDialog
PropTypeDefaultDescription
openbooleanNo defaultControls whether it is open. Control it whenever an action should close it.
defaultOpenbooleanfalseWhether it starts open when uncontrolled.
onOpenChange(open: boolean, eventDetails) => voidNo defaultCalled when Esc, Cancel or a trigger changes it.
onOpenChangeComplete(open: boolean) => voidNo defaultCalled after the open or close animation ends.
actionsRefRefObject<{ close: () => void; unmount: () => void }>No defaultImperative close and unmount.

AlertDialogTrigger

The button that opens it.

Other props spread onto Base UI AlertDialog.Trigger (<button>).

Props of AlertDialogTrigger
PropTypeDefaultDescription
renderReactElement | (props, state) => ReactElementNo defaultRender 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.

Props of AlertDialogContent
PropTypeDefaultDescription
size"default" | "sm""default"sm stays 20rem wide, centers the header and splits the footer into two equal buttons.
initialFocusboolean | RefObject<HTMLElement> | (openType) => HTMLElement | boolean | null | voidNo defaultWhere focus goes on open. Defaults to the first focusable element.
finalFocusboolean | RefObject<HTMLElement> | (closeType) => HTMLElement | boolean | null | voidNo defaultWhere focus goes on close. Defaults to the trigger.
classNamestringNo defaultMerged 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.

AlertDialogFooter

The Well Gray band over a hairline.

Other props spread onto <div>.

No props of its own.

AlertDialogAction

An answer. A Button with data-slot; it doesn't close the dialog.

Other props spread onto Button.

Props of AlertDialogAction
PropTypeDefaultDescription
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.

Props of AlertDialogCancel
PropTypeDefaultDescription
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.