Confirm Action
Guards irreversible actions behind a confirm dialog — with built-in async loading state on the confirm button.
import { ConfirmAction } from "@/components/ui/confirm-action"";import { Button } from "@/components/ui/button";import { toast } from "sonner";
const deleteItem = async () => { await new Promise((resolve) => setTimeout(resolve, 1000));};
export function BasicConfirmAction() { return ( <ConfirmAction title="Delete item?" description="This will permanently delete the item. This action cannot be undone." confirmText="Delete" confirmVariant="destructive" action={deleteItem} onSuccess={() => toast.success("Item deleted.")} > <Button variant="destructive">Delete an Item</Button> </ConfirmAction> );}Installation
Section titled “Installation”This component relies on other items which must be installed first
Copy and paste the following code into your project.
components/ui/confirm-action.tsx
import { AlertDialog, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogMedia, AlertDialogTitle, AlertDialogTrigger,} from "@/components/ui/alert-dialog";import { type ComponentProps, type ReactNode, useState } from "react";import { Button } from "@/components/ui/button";import { AsyncButton, type AsyncButtonProps,} from "@/components/ui/async-button";
interface ConfirmActionProps<TData = unknown, TError = unknown> extends Omit< AsyncButtonProps<TData, TError, []>, "title"> { title?: ReactNode; description?: ReactNode; cancelText?: ReactNode; confirmText?: ReactNode; confirmVariant?: ComponentProps<typeof Button>["variant"]; size?: ComponentProps<typeof AlertDialogContent>["size"]; media?: ReactNode; children: ReactNode;}
/** * Guards inreversible actions behind a confirm dialog * * **Required:** `action` */export function ConfirmAction<TData = unknown, TError = unknown>({ action, title = "Are you sure?", description = "This action cannot be undone.", cancelText = "Cancel", confirmText = "Continue", confirmVariant = "default", size, media, loadingText, onSuccess, onError, onSettled, children,}: ConfirmActionProps<TData, TError>) { const [open, setOpen] = useState(false);
return ( <AlertDialog open={open} onOpenChange={setOpen}> <AlertDialogTrigger asChild>{children}</AlertDialogTrigger>
<AlertDialogContent size={size}> <AlertDialogHeader> {media && <AlertDialogMedia>{media}</AlertDialogMedia>} <AlertDialogTitle>{title}</AlertDialogTitle> <AlertDialogDescription>{description}</AlertDialogDescription> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogCancel>{cancelText}</AlertDialogCancel> <AsyncButton variant={confirmVariant} action={action} loadingText={loadingText ?? confirmText} onSuccess={(data, args) => { onSuccess?.(data, args); setOpen(false); }} onError={onError} onSettled={onSettled} > {confirmText} </AsyncButton> </AlertDialogFooter> </AlertDialogContent> </AlertDialog> );}Update the import paths to match your project setup.
import { ConfirmAction } from "@/components/ui/confirm-action";<ConfirmAction title="Delete item?" description="This action cannot be undone." confirmText="Delete" confirmVariant="destructive" action={deleteItem} onSuccess={() => toast.success("Deleted")}> <Button variant="destructive">Delete</Button></ConfirmAction>The child element becomes the dialog trigger. Any clickable element works — a button, an icon, a menu item.
Examples
Section titled “Examples”Custom Trigger
Section titled “Custom Trigger”The trigger can be anything. Here a ghost trash icon appears on row hover — invisible until the user signals intent, then confirms before the action runs.
Review pull request
Code Review
Update dependencies
Maintenance
Write release notes
Docs
import { Button } from "@/components/ui/button";import { ConfirmAction } from "@/components/ui/confirm-action"";import { Trash2Icon } from "lucide-react";import { useState } from "react";import { toast } from "sonner";
const INITIAL_TASKS = [ { id: 1, title: "Review pull request", label: "Code Review" }, { id: 2, title: "Update dependencies", label: "Maintenance" }, { id: 3, title: "Write release notes", label: "Docs" },];
export function CustomTriggerConfirmAction() { const [tasks, setTasks] = useState(INITIAL_TASKS);
if (tasks.length === 0) { return ( <Button variant="outline" onClick={() => setTasks(INITIAL_TASKS)}> Reset tasks </Button> ); }
return ( <div className="w-80 divide-y overflow-hidden rounded-xl border"> {tasks.map((task) => ( <div key={task.id} className="group flex items-center justify-between px-4 py-3" > <div className="flex flex-col gap-0.5"> <p className="text-sm font-medium">{task.title}</p> <p className="text-xs text-muted-foreground">{task.label}</p> </div>
<ConfirmAction title="Remove task?" description={`"${task.title}" will be permanently removed.`} confirmText="Remove" confirmVariant="destructive" action={async () => { await new Promise((r) => setTimeout(r, 800)); setTasks((t) => t.filter((x) => x.id !== task.id)); }} onSuccess={() => toast.success("Task removed")} > <Button variant="ghost" size="icon" className="size-7 text-muted-foreground opacity-0 transition-opacity hover:bg-destructive/10 hover:text-destructive group-hover:opacity-100" > <Trash2Icon className="size-3.5" /> </Button> </ConfirmAction> </div> ))} </div> );}With Media
Section titled “With Media”Pass any icon or image to media to give the dialog visual context. The icon renders in a muted tile beside the title, making the dialog feel grounded in the action it’s describing.
Production API Key
sk_live_••••••••3f9a
import { Button } from "@/components/ui/button";import { ConfirmAction } from "@/components/ui/confirm-action"";import { KeyRoundIcon, ShieldCheckIcon } from "lucide-react";import { useState } from "react";import { toast } from "sonner";
export function WithMediaConfirmAction() { const [revoked, setRevoked] = useState(false);
const revokeKey = async () => { await new Promise((r) => setTimeout(r, 1000)); setRevoked(true); };
return ( <div className="flex w-80 flex-col gap-3 rounded-xl border p-4"> <div className="flex items-start justify-between gap-4"> <div className="flex flex-col gap-1"> <p className="text-sm font-medium">Production API Key</p> <p className="font-mono text-xs text-muted-foreground"> sk_live_••••••••3f9a </p> <div className="flex items-center gap-1.5"> {revoked ? ( <span className="text-xs text-destructive">Revoked</span> ) : ( <> <ShieldCheckIcon className="size-3 text-emerald-500" /> <span className="text-xs text-muted-foreground"> Last used 2 hours ago </span> </> )} </div> </div>
{revoked ? ( <Button variant="outline" size="sm" onClick={() => setRevoked(false)}> Restore </Button> ) : ( <ConfirmAction title="Revoke API key?" description="Any service using this key will immediately lose access. This cannot be undone." confirmText="Revoke key" confirmVariant="destructive" media={<KeyRoundIcon />} action={revokeKey} onSuccess={() => toast.success("API key revoked")} > <Button variant="outline" size="sm"> Revoke </Button> </ConfirmAction> )} </div> </div> );}On Success
Section titled “On Success”Use onSuccess to access the data returned by action — useful for showing a confirmation message with context from the server response.
import { ConfirmAction } from "@/components/ui/confirm-action"";import { Button } from "@/components/ui/button";import { toast } from "sonner";
const archiveItem = async (): Promise<{ archivedAt: string }> => { await new Promise((resolve) => setTimeout(resolve, 1000)); return { archivedAt: new Date().toISOString() };};
export function WithFollowUpConfirmAction() { return ( <ConfirmAction title="Archive this item?" description="Archived items can be restored later from your archive." confirmText="Archive" action={archiveItem} onSuccess={(data: { archivedAt: string }) => toast.success("Archived", { description: `Archived at ${new Date(data.archivedAt).toLocaleTimeString()}`, }) } > <Button variant="outline">Archive item</Button> </ConfirmAction> );}On Error
Section titled “On Error”onError fires if action throws. The dialog stays open so the user can try again without re-triggering the confirmation flow.
import { ConfirmAction } from "@/components/ui/confirm-action"";import { Button } from "@/components/ui/button";import { toast } from "sonner";
const failingAction = async () => { await new Promise((resolve) => setTimeout(resolve, 1000)); throw new Error("Server error");};
export function ErrorConfirmAction() { return ( <ConfirmAction title="This will fail" description="This example demonstrates how ConfirmAction handles errors gracefully." confirmText="Proceed anyway" confirmVariant="destructive" action={failingAction} onSuccess={() => toast.success("Done.")} onError={() => toast.error("Action failed. Please try again.")} > <Button variant="destructive">Trigger error</Button> </ConfirmAction> );}| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | — | The trigger element that opens the dialog |
action | () => Promise<TData> | TData | — | Async function executed when the user confirms |
title | ReactNode | "Are you sure?" | Dialog heading |
description | ReactNode | "This action cannot be undone." | Dialog body text |
confirmText | ReactNode | "Continue" | Label for the confirm button |
cancelText | ReactNode | "Cancel" | Label for the cancel button |
confirmVariant | Button variant | "default" | Visual style of the confirm button |
loadingText | ReactNode | confirmText | Text shown on the confirm button while the action runs |
media | ReactNode | — | Icon or image rendered above the title inside the dialog |
size | "default" | "sm" | — | Controls dialog width |
onSuccess | (data: TData) => void | — | Called after action resolves — receives the return value |
onError | (error: TError) => void | — | Called if action throws |
onSettled | (data, error) => void | — | Called after either outcome |