Alert dialog
A modal question that has to be answered before the page goes on.
Vibra draws the popup as the kit's elev-2 modal layer over the --overlay scrim instead of 10% black, and has it rise 8px on the emphasized ease rather than zoom, leaving on the fast exit curve; the footer strip is divided by --rule and the title set at text-md. A title given one of the theme's type registers (type-display text-2xl) takes it whole and drops its own face and weight, as DialogTitle does. The header aligns to the inline start where shadcn's aligns left, so a right-to-left dialog reads from the right, and answers that outgrow the footer's row wrap onto a second one instead of running out of the popup. ConfirmDialog is this with the confirm awaited and a typed-word gate.
Install
npx shadcn@latest add @vibra/alert-dialogNeeds the @vibra registry in your components.json — set it up once.
Examples
"use client"
import * as React from "react"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
export default function AlertDialogDemo() {
const [open, setOpen] = React.useState(false)
const [revoked, setRevoked] = React.useState(false)
return (
<div className="flex flex-col items-center gap-3">
<AlertDialog open={open} onOpenChange={setOpen}>
{/* Spent, the trigger stays focusable: the dialog hands the focus back
to it on closing, and a natively disabled button would drop it on
the page. */}
<AlertDialogTrigger render={<Button variant="outline" disabled={revoked} focusableWhenDisabled />}>
Revoke API key
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Revoke “Production ingest”?</AlertDialogTitle>
<AlertDialogDescription>
Requests signed with this key start failing at once. The ingest worker and the nightly export still use
it.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Keep key</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => {
setRevoked(true)
setOpen(false)
}}
>
Revoke key
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<p role="status" className="text-xs text-muted-foreground">
{revoked ? "Production ingest was revoked." : "Production ingest · last used 2 minutes ago"}
</p>
</div>
)
}Small
size="sm": the header centred over its icon, and the two answers side by side.
"use client"
import * as React from "react"
import { LogOutIcon } from "lucide-react"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
// size="sm": the header centred over the icon and the two answers side by
// side, half the width each — a short question with a short answer.
export default function AlertDialogSmall() {
const [open, setOpen] = React.useState(false)
const [done, setDone] = React.useState(false)
return (
<div className="flex flex-col items-center gap-3">
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogTrigger render={<Button variant="outline" />}>Sign out everywhere</AlertDialogTrigger>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogMedia>
<LogOutIcon aria-hidden="true" />
</AlertDialogMedia>
<AlertDialogTitle>Sign out of 4 other devices?</AlertDialogTitle>
<AlertDialogDescription>This browser stays signed in. The others ask for a password next time.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
setDone(true)
setOpen(false)
}}
>
Sign out
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<p role="status" aria-live="polite" className="text-xs text-muted-foreground">
{done ? "Signed out of 4 other devices." : "Signed in on this browser, two phones and a tablet"}
</p>
</div>
)
}With an icon
AlertDialogMedia beside the title from the sm breakpoint. A question with no danger in it answers with the default fill.
"use client"
import * as React from "react"
import { SendIcon } from "lucide-react"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
// AlertDialogMedia at the default size: beside the title from the sm
// breakpoint, over it on a phone. A question with no danger in it, so the
// answer is the default fill and the tile stays on the muted plane.
export default function AlertDialogIcon() {
const [open, setOpen] = React.useState(false)
const [published, setPublished] = React.useState(false)
return (
<div className="flex flex-col items-center gap-3">
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogTrigger render={<Button />}>
<SendIcon data-icon="inline-start" aria-hidden="true" />
Publish report
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogMedia>
<SendIcon aria-hidden="true" />
</AlertDialogMedia>
<AlertDialogTitle>Publish the Q3 board report?</AlertDialogTitle>
<AlertDialogDescription>
The six board members get an email with a link. You can keep editing after it is published.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Not yet</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
setPublished(true)
setOpen(false)
}}
>
Publish
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<p role="status" aria-live="polite" className="text-xs text-muted-foreground">
{published ? "Published. Six board members were emailed." : "Q3 board report · draft, edited 12 minutes ago"}
</p>
</div>
)
}Destructive, with what goes
The deletion says what it takes with it, in a description that holds the list, so it is read on opening.
"use client"
import * as React from "react"
import { Trash2Icon } from "lucide-react"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
const LOSSES = ["14 dashboards", "3 scheduled exports", "212 comments"]
// A deletion says what goes with it before it asks. The description renders
// as a div so the list is part of what a screen reader hears on opening, and
// both buttons name the thing. The spent trigger stays focusable, so the
// focus the dialog hands back has somewhere to land.
export default function AlertDialogDestructive() {
const [open, setOpen] = React.useState(false)
const [deleted, setDeleted] = React.useState(false)
return (
<div className="flex flex-col items-center gap-3">
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogTrigger render={<Button variant="destructive" disabled={deleted} focusableWhenDisabled />}>
<Trash2Icon data-icon="inline-start" aria-hidden="true" />
Delete workspace
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete the Q3 launch workspace?</AlertDialogTitle>
<AlertDialogDescription render={<div />}>
<p>This can’t be undone. Everything in it goes too:</p>
<ul className="mt-1.5 list-inside list-disc text-foreground tabular-nums">
{LOSSES.map((loss) => (
<li key={loss}>{loss}</li>
))}
</ul>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Keep workspace</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => {
setDeleted(true)
setOpen(false)
}}
>
Delete workspace
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<p role="status" aria-live="polite" className="text-xs text-muted-foreground">
{deleted ? "Q3 launch was deleted." : "Q3 launch · 14 dashboards, last opened today"}
</p>
</div>
)
}Three answers
Discard, keep editing or save. The focus opens on the one answer Enter can't regret.
"use client"
import * as React from "react"
import { ArrowLeftIcon } from "lucide-react"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
// Three answers, so the popup takes a size up to keep them on one row.
// Discarding sits apart at the start (and last on a phone, where the footer
// stacks the other way round), saving takes the fill, and the focus opens on
// "Keep editing" — the one answer Enter can't regret.
export default function AlertDialogUnsaved() {
const [open, setOpen] = React.useState(false)
const [outcome, setOutcome] = React.useState<string | null>(null)
const keepEditing = React.useRef<HTMLButtonElement>(null)
function leave(message: string) {
setOutcome(message)
setOpen(false)
}
return (
<div className="flex flex-col items-center gap-3">
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogTrigger render={<Button variant="ghost" />}>
<ArrowLeftIcon data-icon="inline-start" aria-hidden="true" className="rtl:rotate-180" />
Back to invoices
</AlertDialogTrigger>
<AlertDialogContent initialFocus={keepEditing} className="data-[size=default]:sm:max-w-md">
<AlertDialogHeader>
<AlertDialogTitle>Save your changes to INV-2041?</AlertDialogTitle>
<AlertDialogDescription>
You changed the due date and the second line item. Leaving without saving throws both away.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogAction variant="ghost" className="sm:me-auto" onClick={() => leave("Discarded your changes to INV-2041.")}>
Discard changes
</AlertDialogAction>
<AlertDialogCancel ref={keepEditing}>Keep editing</AlertDialogCancel>
<AlertDialogAction onClick={() => leave("Saved INV-2041 and went back to Invoices.")}>Save and leave</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<p role="status" aria-live="polite" className="text-xs text-muted-foreground">
{outcome ?? "Editing INV-2041 · 2 unsaved changes"}
</p>
</div>
)
}Gated by a checkbox
The action waits for a precondition, focusable all the while and described by what it waits for.
"use client"
import * as React from "react"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { Field, FieldLabel } from "@/components/ui/field"
// An answer that needs a precondition met first: the action waits for the
// box, focusable all the while and described by what it is waiting for. For a
// typed-word gate on a deletion, ConfirmDialog.
export default function AlertDialogCheckbox() {
const id = React.useId()
const [open, setOpen] = React.useState(false)
const [ready, setReady] = React.useState(false)
const [rotated, setRotated] = React.useState(false)
return (
<div className="flex flex-col items-center gap-3">
<AlertDialog
open={open}
onOpenChange={(next) => {
setOpen(next)
if (!next) setReady(false)
}}
>
<AlertDialogTrigger render={<Button variant="outline" />}>Rotate signing secret</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Rotate the webhook signing secret?</AlertDialogTitle>
<AlertDialogDescription>
The current secret stops working the moment the new one exists. Every consumer still using it fails until
it switches.
</AlertDialogDescription>
</AlertDialogHeader>
<Field orientation="horizontal">
<Checkbox id={`${id}-ready`} checked={ready} onCheckedChange={setReady} />
<FieldLabel htmlFor={`${id}-ready`} className="font-normal">
I’ve given the new secret to the ingest worker and the nightly export
</FieldLabel>
</Field>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={!ready}
focusableWhenDisabled
aria-describedby={ready ? undefined : `${id}-why`}
onClick={() => {
setRotated(true)
setOpen(false)
}}
>
Rotate secret
</AlertDialogAction>
<span id={`${id}-why`} className="sr-only">
Tick the box once both consumers have the new secret.
</span>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<p role="status" aria-live="polite" className="text-xs text-muted-foreground">
{rotated ? "Rotated. The new secret starts whsec_7Qm." : "Signing secret · created on June 3"}
</p>
</div>
)
}Long content
The middle scrolls between a header and a footer that stay put, as a named region the keyboard reaches first.
"use client"
import * as React from "react"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
const CHANGES = [
["Sub-processors", "Two join the list: an EU mail relay for invoices, and a backup store in Frankfurt."],
["Retention", "Deleted workspaces are purged after 30 days rather than 90."],
["Transfers", "Data leaves the EU only under the 2021 standard contractual clauses."],
["Breach notice", "Northwind hears within 48 hours of a confirmed breach, down from 72."],
["Audits", "One audit a year on 30 days’ notice, at Northwind’s cost."],
]
// Long reading between a header and a footer that stay put. The middle
// scrolls on its own, and as a named region it takes the focus first, so a
// keyboard can scroll it before reaching the answers.
export default function AlertDialogScroll() {
const [open, setOpen] = React.useState(false)
const [accepted, setAccepted] = React.useState(false)
return (
<div className="flex flex-col items-center gap-3">
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogTrigger render={<Button variant="outline" />}>Review the new DPA</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Accept version 3 of the data processing addendum?</AlertDialogTitle>
<AlertDialogDescription>It covers the whole workspace and takes effect on November 1.</AlertDialogDescription>
</AlertDialogHeader>
<div role="region" aria-label="What changes in version 3" tabIndex={0} className="-mx-1 max-h-44 overflow-y-auto overscroll-contain rounded-lg border px-3 py-2 focus-ring">
<dl className="flex flex-col gap-2.5 text-sm">
{CHANGES.map(([term, change]) => (
<div key={term}>
<dt className="font-medium">{term}</dt>
<dd className="text-muted-foreground">{change}</dd>
</div>
))}
</dl>
</div>
<AlertDialogFooter>
<AlertDialogCancel>Not now</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
setAccepted(true)
setOpen(false)
}}
>
Accept version 3
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<p role="status" aria-live="polite" className="text-xs text-muted-foreground">
{accepted ? "Version 3 accepted for Northwind." : "DPA version 2 · signed in March 2025"}
</p>
</div>
)
}Waits for the action
The action stays busy, holding the focus, and the dialog stays open until the refund goes through.
"use client"
import * as React from "react"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
import { Spinner } from "@/components/ui/spinner"
// The dialog waits for the work it started. While the refund goes through the
// action is busy — disabled, but still holding the focus — the way out is
// disabled, and Escape is ignored; the dialog closes when the answer is in.
export default function AlertDialogPending() {
const [open, setOpen] = React.useState(false)
const [pending, setPending] = React.useState(false)
const [refunded, setRefunded] = React.useState(false)
const timer = React.useRef<number>(undefined)
React.useEffect(() => () => window.clearTimeout(timer.current), [])
function refund() {
setPending(true)
timer.current = window.setTimeout(() => {
setPending(false)
setRefunded(true)
setOpen(false)
}, 1200)
}
return (
<div className="flex flex-col items-center gap-3">
<AlertDialog open={open} onOpenChange={(next) => { if (!pending) setOpen(next) }}>
<AlertDialogTrigger render={<Button variant="outline" disabled={refunded} focusableWhenDisabled />}>
Refund INV-2041
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Refund $1,240.00 to Blue Harbor Logistics?</AlertDialogTitle>
<AlertDialogDescription>
The money goes back to the card it came from within 10 working days, and INV-2041 is marked refunded.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={pending}>Don’t refund</AlertDialogCancel>
<AlertDialogAction disabled={pending} focusableWhenDisabled aria-busy={pending || undefined} onClick={refund}>
{pending ? <Spinner data-icon="inline-start" aria-hidden="true" /> : null}
{pending ? "Refunding…" : "Refund $1,240.00"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<p role="status" aria-live="polite" className="text-xs text-muted-foreground">
{refunded ? "Refund RF-2091 is on its way to Blue Harbor Logistics." : "INV-2041 · paid $1,240.00 on September 12"}
</p>
</div>
)
}When the action fails
A danger callout where the reader is looking, and the same button offering to try again.
"use client"
import * as React from "react"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
import { Callout } from "@/components/ui/callout"
import { Spinner } from "@/components/ui/spinner"
// When the answer can't be carried out, the dialog stays and says so where the
// reader is looking: a danger callout that announces itself, above the action,
// which now offers to try again and never lost the focus. The second attempt
// goes through here, so both outcomes can be tried.
export default function AlertDialogError() {
const [open, setOpen] = React.useState(false)
const [state, setState] = React.useState<"asking" | "working" | "failed" | "done">("asking")
const attempts = React.useRef(0)
const timer = React.useRef<number>(undefined)
React.useEffect(() => () => window.clearTimeout(timer.current), [])
const working = state === "working"
function cancelPlan() {
setState("working")
attempts.current += 1
const fails = attempts.current === 1
timer.current = window.setTimeout(() => {
setState(fails ? "failed" : "done")
if (!fails) setOpen(false)
}, 900)
}
return (
<div className="flex flex-col items-center gap-3">
<AlertDialog open={open} onOpenChange={(next) => { if (!working) setOpen(next) }}>
<AlertDialogTrigger render={<Button variant="outline" disabled={state === "done"} focusableWhenDisabled />}>
Cancel Enterprise
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Cancel the Enterprise plan?</AlertDialogTitle>
<AlertDialogDescription>
Northwind keeps Enterprise until October 31, then moves to Team. Single sign-on and the audit log switch
off that day.
</AlertDialogDescription>
</AlertDialogHeader>
{state === "failed" ? (
<Callout variant="danger" role="alert" title="The billing provider didn’t answer">
Nothing was changed. Try again in a moment.
</Callout>
) : null}
<AlertDialogFooter>
<AlertDialogCancel disabled={working}>Keep Enterprise</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={working}
focusableWhenDisabled
aria-busy={working || undefined}
onClick={cancelPlan}
>
{working ? <Spinner data-icon="inline-start" aria-hidden="true" /> : null}
{working ? "Cancelling…" : state === "failed" ? "Try again" : "Cancel Enterprise"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<p role="status" aria-live="polite" className="text-xs text-muted-foreground">
{state === "done" ? "Enterprise ends on October 31." : "Enterprise · renews on November 1"}
</p>
</div>
)
}Opened from a menu
Opened from state, since the menu is gone by then; finalFocus hands the focus back to the menu button.
"use client"
import * as React from "react"
import { EllipsisIcon } from "lucide-react"
import { getInitials } from "@/lib/format"
import { avatarFor } from "@/lib/avatars"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
const MEMBER = "Lior Keller"
// A menu item can't be the trigger — the menu is gone by the time the dialog
// opens — so the dialog is opened from state, and finalFocus names where the
// focus goes when it closes: back to the menu button, not the page.
export default function AlertDialogMenu() {
const [open, setOpen] = React.useState(false)
const [removed, setRemoved] = React.useState(false)
const menuButton = React.useRef<HTMLButtonElement>(null)
return (
<div className="flex w-full max-w-sm flex-col gap-3">
<div className="flex items-center gap-3 rounded-lg border px-3 py-2">
<Avatar aria-hidden="true">
<AvatarImage src={avatarFor(MEMBER)} alt="" />
<AvatarFallback>{getInitials(MEMBER)}</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1 text-sm">
<p className="font-medium">{MEMBER}</p>
<p className="truncate text-xs text-muted-foreground">{removed ? "Removed just now" : "Admin · lior@northwind.example"}</p>
</div>
<DropdownMenu>
<DropdownMenuTrigger
ref={menuButton}
render={<Button variant="ghost" size="icon-sm" aria-label={`Actions for ${MEMBER}`} disabled={removed} focusableWhenDisabled />}
>
<EllipsisIcon aria-hidden="true" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem>Change role</DropdownMenuItem>
<DropdownMenuItem>Reset two-step verification</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onClick={() => setOpen(true)}>
Remove from workspace…
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogContent finalFocus={menuButton}>
<AlertDialogHeader>
<AlertDialogTitle>Remove {MEMBER} from Northwind?</AlertDialogTitle>
<AlertDialogDescription>Lior loses access to every dashboard at once. The reports Lior made stay, and pass to you.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Keep {MEMBER}</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => {
setRemoved(true)
setOpen(false)
}}
>
Remove {MEMBER}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<p role="status" aria-live="polite" className="sr-only">
{removed ? `${MEMBER} was removed from Northwind.` : ""}
</p>
</div>
)
}Opened by the app
An idle session about to end. Staying is the fill, the cancel and the first focus.
"use client"
import * as React from "react"
import { TimerIcon } from "lucide-react"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
// The one the app opens on its own: an idle session about to end. Staying is
// the answer nearly everyone wants, so it is the fill, the dialog's cancel —
// Escape stays too — and where the focus opens, so Enter keeps the reader in.
// The settings row is where an admin previews what members will see.
export default function AlertDialogTimeout() {
const [open, setOpen] = React.useState(false)
const [signedOut, setSignedOut] = React.useState(false)
const stay = React.useRef<HTMLButtonElement>(null)
return (
<div className="flex w-full max-w-sm items-center justify-between gap-3 rounded-lg border px-3 py-2.5">
<div className="text-sm">
<p className="font-medium">Sign out after 30 idle minutes</p>
<p role="status" aria-live="polite" className="text-xs text-muted-foreground">
{signedOut ? "The preview signed you out." : "Members get a warning 5 minutes before."}
</p>
</div>
<AlertDialog
open={open}
onOpenChange={(next) => {
setOpen(next)
if (next) setSignedOut(false)
}}
>
<AlertDialogTrigger render={<Button variant="outline" size="sm" />}>Preview</AlertDialogTrigger>
<AlertDialogContent initialFocus={stay}>
<AlertDialogHeader>
<AlertDialogMedia>
<TimerIcon aria-hidden="true" />
</AlertDialogMedia>
<AlertDialogTitle>You’ll be signed out at 14:32</AlertDialogTitle>
<AlertDialogDescription>
You’ve been away for 25 minutes. Northwind signs idle sessions out to keep invoices private; anything
unsaved is kept as a draft.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogAction
variant="outline"
onClick={() => {
setSignedOut(true)
setOpen(false)
}}
>
Sign out now
</AlertDialogAction>
<AlertDialogCancel ref={stay} variant="default">
Stay signed in
</AlertDialogCancel>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}Right to left
The popup is portalled out of the page, so it carries dir and lang itself.
"use client"
import * as React from "react"
import { KeyRoundIcon } from "lucide-react"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
// The demo's question in Arabic. The popup is portalled to the body, outside
// any dir on the page, so it carries dir and lang itself; the header then
// starts at the right and the icon sits on the right of the title.
export default function AlertDialogRtl() {
const [open, setOpen] = React.useState(false)
const [revoked, setRevoked] = React.useState(false)
return (
<div dir="rtl" lang="ar" className="flex flex-col items-center gap-3">
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogTrigger render={<Button variant="outline" disabled={revoked} focusableWhenDisabled />}>
إبطال مفتاح API
</AlertDialogTrigger>
<AlertDialogContent dir="rtl" lang="ar">
<AlertDialogHeader>
<AlertDialogMedia>
<KeyRoundIcon aria-hidden="true" />
</AlertDialogMedia>
<AlertDialogTitle>إبطال مفتاح الإنتاج؟</AlertDialogTitle>
<AlertDialogDescription>
تفشل الطلبات الموقّعة بهذا المفتاح على الفور، وما زال عامل الاستيراد والتصدير الليلي يستخدمانه.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>الإبقاء على المفتاح</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => {
setRevoked(true)
setOpen(false)
}}
>
إبطال المفتاح
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<p role="status" aria-live="polite" className="text-xs text-muted-foreground">
{revoked ? "أُبطل مفتاح الإنتاج." : "مفتاح الإنتاج · آخر استخدام قبل دقيقتين"}
</p>
</div>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| AlertDialogContent.size | "default" | "sm" | "default" | sm narrows the dialog and puts the two actions side by side. |
| AlertDialogContent.initialFocus / finalFocus | boolean | React.RefObject<HTMLElement | null> | ((type: InteractionType) => HTMLElement | boolean | null | void) | the first control / the trigger | Where the focus lands on opening and returns on closing. Name them when the safe answer is not the first button, or when a menu item opened the dialog. |
| AlertDialogMedia | React.ComponentProps<"div"> | — | An icon tile: beside the title at the default size from the sm breakpoint, over it otherwise. |
| AlertDialogCancel.variant / size | ButtonProps["variant"] / ButtonProps["size"] | "outline" / "default" | The cancel button's look; AlertDialogAction takes every Button prop. |
Dependencies
Registry
Source
"use client"
import * as React from "react"
import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: AlertDialogPrimitive.Backdrop.Props) {
return (
<AlertDialogPrimitive.Backdrop
data-slot="alert-dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-overlay supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-open:duration-(--duration-base) data-open:ease-(--ease-standard) data-closed:animate-out data-closed:fade-out-0 data-closed:duration-(--duration-fast) data-closed:ease-(--ease-exit)",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
size = "default",
...props
}: AlertDialogPrimitive.Popup.Props & {
size?: "default" | "sm"
}) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Popup
data-slot="alert-dialog-content"
data-size={size}
className={cn(
"group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 elev-2 p-4 text-popover-foreground outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:slide-in-from-bottom-2 data-open:duration-(--duration-slow) data-open:ease-(--ease-emphasized) data-closed:animate-out data-closed:fade-out-0 data-closed:duration-(--duration-fast) data-closed:ease-(--ease-exit)",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn(
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-start sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
className
)}
{...props}
/>
)
}
// Answers that outgrow the row wrap onto a second one rather than running out
// of the popup; a dialog with three long answers is better given the room
// (a wider max-w on AlertDialogContent), and this is what happens if not.
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t border-rule bg-muted/50 p-4 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:flex-wrap sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogMedia({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-media"
className={cn(
"mb-2 inline-flex size-10 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6",
className
)}
{...props}
/>
)
}
/**
* A class naming one of the theme's type registers — type-display and its kin.
* A register sets the family, the weight and the tracking in one class, which
* the class merge cannot see as replacing the title's own type, so the two
* stayed side by side and the stylesheet's order decided between them.
*/
const TYPE_REGISTER = /(?:^|\s)type-[a-z]/
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
// A title given a register keeps it whole; otherwise the title's own type
// applies, and any single class still replaces its counterpart.
const ownType = typeof className === "string" && TYPE_REGISTER.test(className)
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn(
!ownType && "font-heading text-md font-medium",
"sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
className
)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn(
"text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof Button>) {
return (
<Button
data-slot="alert-dialog-action"
className={cn(className)}
{...props}
/>
)
}
function AlertDialogCancel({
className,
variant = "outline",
size = "default",
...props
}: AlertDialogPrimitive.Close.Props &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<AlertDialogPrimitive.Close
data-slot="alert-dialog-cancel"
className={cn(className)}
render={<Button variant={variant} size={size} />}
{...props}
/>
)
}
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
}