Confirm dialog
A one-question alert dialog that awaits its confirm handler and can gate it behind a typed word.
A client component. Leave open unset and the trigger drives it; pass open and onOpenChange to own it, in which case only the owner can close it. onConfirm is awaited before the dialog closes, so the confirm button spins on the real work. confirmationWord is compared exactly, after trimming, and the field is cleared whenever the dialog closes so reopening never starts pre-confirmed. It has no className or rest props: the alert-dialog parts underneath carry the styling.
Install
$
npx shadcn@latest add @vibra/confirm-dialogNeeds the @vibra registry in your components.json — set it up once.
Examples
"use client"
import * as React from "react"
import { ArchiveIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { ConfirmDialog } from "@/components/ui/confirm-dialog"
export default function ConfirmDialogDemo() {
const [archived, setArchived] = React.useState(false)
return (
<div className="flex w-full max-w-md flex-col items-start gap-3">
<ConfirmDialog
trigger={
<Button variant="outline">
<ArchiveIcon aria-hidden="true" />
Archive report
</Button>
}
title="Archive “Weekly revenue”?"
description="It leaves the dashboard list and stops refreshing. You can restore it from Archived at any time."
confirmText="Archive"
onConfirm={async () => {
await new Promise((resolve) => setTimeout(resolve, 900))
setArchived(true)
}}
>
<p>3 scheduled exports will stop with it.</p>
</ConfirmDialog>
<p className="text-sm text-muted-foreground">
{archived ? "Archived. Restore it from the Archived tab." : "Nothing archived yet."}
</p>
</div>
)
}Destructive with a typed confirmation
The confirm button stays disabled until the workspace name is typed exactly.
"use client"
import * as React from "react"
import { Trash2Icon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { ConfirmDialog } from "@/components/ui/confirm-dialog"
export default function ConfirmDialogDestructive() {
const [deleted, setDeleted] = React.useState(false)
return (
<div className="flex w-full max-w-md flex-col items-start gap-3">
<ConfirmDialog
variant="destructive"
trigger={
<Button variant="destructive">
<Trash2Icon aria-hidden="true" />
Delete workspace
</Button>
}
title="Delete northwind-prod?"
description="Every dashboard, query, and saved filter in this workspace is removed. This cannot be undone."
confirmationWord="northwind-prod"
confirmText="Delete workspace"
cancelText="Keep it"
onConfirm={async () => {
await new Promise((resolve) => setTimeout(resolve, 900))
setDeleted(true)
}}
/>
<p className="text-sm text-muted-foreground">
{deleted
? "Workspace deleted."
: "Confirm stays disabled until the workspace name is typed exactly."}
</p>
</div>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| title | React.ReactNode | — | The question, phrased so that confirming is unambiguous. |
| description | React.ReactNode | — | What confirming actually does, and whether it can be undone. |
| trigger | React.ReactNode | — | Rendered as the trigger itself, not wrapped in one, so pass an element. |
| open | boolean | — | Controls the dialog; leave it off and the trigger opens and closes it on its own. |
| onOpenChange | (open: boolean) => void | — | Called on every open and close, including the one after a confirm. |
| onConfirm | () => void | Promise<void> | — | Awaited before the dialog closes; reject to keep the dialog open. |
| confirmText | string | Confirm | Label for the confirm button; name the action, not the agreement. |
| cancelText | string | Cancel | Label for the cancel button. |
| variant | "default" | "destructive" | "default" | destructive turns the confirm button red. |
| confirmationWord | string | — | Gates confirm behind typing this exact word — for deletes that cannot be undone. |
| loading | boolean | false | Forces the confirm button's spinner, for work tracked outside the dialog. |
| children | React.ReactNode | — | Extra detail inside the dialog, below the description. |
| className | string | — | Merged onto the dialog content, which is the root; the remaining props are spread onto it too — the trigger is your own element. |
Dependencies
Source
"use client"
import * as React from "react"
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { AsyncButton } from "@/components/ui/async-button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
// The dialog content is the root: className and the rest of the props land on
// it, not on the trigger, which is the caller's own element. `title` and
// `children` are re-declared because the item owns them — the heading and the
// extra detail under the description.
export type ConfirmDialogProps = Omit<
React.ComponentProps<typeof AlertDialogContent>,
"title" | "children" | "className" | "render" | "size"
> & {
className?: string
/** Controls the dialog. Leave it off and the trigger opens and closes it on its own. */
open?: boolean
onOpenChange?: (open: boolean) => void
/** The element that opens the dialog — rendered as the trigger itself, not wrapped in one. */
trigger?: React.ReactNode
title: React.ReactNode
description?: React.ReactNode
confirmText?: string
cancelText?: string
variant?: "default" | "destructive"
/** Gates confirm behind typing this exact word — for deletes that cannot be undone. */
confirmationWord?: string
/** Awaited before the dialog closes, so the confirm button spins until the work is done. */
onConfirm: () => void | Promise<void>
loading?: boolean
/** Extra detail inside the dialog, below the description. */
children?: React.ReactNode
}
function ConfirmDialog({
className,
open,
onOpenChange,
trigger,
title,
description,
confirmText = "Confirm",
cancelText = "Cancel",
variant = "default",
confirmationWord,
onConfirm,
loading = false,
children,
...props
}: ConfirmDialogProps) {
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(false)
const [typed, setTyped] = React.useState("")
const inputId = React.useId()
const isOpen = open ?? uncontrolledOpen
const gated = confirmationWord !== undefined && typed.trim() !== confirmationWord
function setOpen(next: boolean) {
// Re-arm the gate on the way out, so reopening never starts pre-confirmed.
if (!next) setTyped("")
setUncontrolledOpen(next)
onOpenChange?.(next)
}
async function handleConfirm() {
await onConfirm()
setOpen(false)
}
return (
<AlertDialog open={isOpen} onOpenChange={setOpen}>
{trigger ? <AlertDialogTrigger render={trigger as React.ReactElement} /> : null}
<AlertDialogContent
data-slot="confirm-dialog"
data-variant={variant}
className={className}
{...props}
>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
{description ? <AlertDialogDescription>{description}</AlertDialogDescription> : null}
</AlertDialogHeader>
{children ? (
<div data-slot="confirm-dialog-body" className="text-sm text-muted-foreground">
{children}
</div>
) : null}
{confirmationWord !== undefined ? (
<div data-slot="confirm-dialog-confirmation" className="flex flex-col gap-2 text-start">
<Label htmlFor={inputId} className="text-xs font-normal text-muted-foreground">
Type <span className="font-mono font-medium text-foreground">{confirmationWord}</span>{" "}
to confirm
</Label>
<Input
id={inputId}
value={typed}
onChange={(event) => setTyped(event.target.value)}
autoComplete="off"
autoCorrect="off"
spellCheck={false}
className="font-mono text-sm"
/>
</div>
) : null}
<AlertDialogFooter>
<AlertDialogCancel>{cancelText}</AlertDialogCancel>
<AsyncButton
data-slot="confirm-dialog-confirm"
variant={variant === "destructive" ? "destructive" : "default"}
disabled={gated}
loading={loading}
onClick={handleConfirm}
>
{confirmText}
</AsyncButton>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
export { ConfirmDialog }