Confirm
Async confirmation via ConfirmProvider, useConfirm, and ConfirmAction with await, loading, and close-on-success.
Confirm runs a destructive or consequential action inside a dialog: await onConfirm, show loading on the confirm button, close on success, and keep the dialog open on failure. It follows Ant Design Modal.confirm action-in-dialog semantics (Promise onOk), not a boolean-only “decide then mutate outside” API.
Use ConfirmProvider + useConfirm() for imperative calls from handlers (toolbar, menu, command palette). Use ConfirmAction when a single button should own its own confirm dialog — no provider required.
When To Use
- Confirm a mutation before it runs (delete, cancel, revoke) and keep loading inside the dialog until the promise settles.
- Prefer
useConfirmwhen many call sites share one portal under a rootConfirmProvider. - Prefer
ConfirmActionfor a detail/toolbar button that always confirms the same operation. - Do not treat idle cancel as success —
useConfirmrejects withConfirm cancelledwhen the user dismisses without confirming. - Do not use Confirm for inline Popconfirm-style row actions already covered by table row
confirm— that path shares the same async contract separately.
Features
| Area | Behavior |
|---|---|
| Action-in-dialog | onConfirm returns void | Promise<void>; resolve closes; reject keeps open and clears loading |
| Imperative API | ConfirmProvider + useConfirm() — kit owns the AlertDialog portal (Ant App / useModal class) |
| ConfirmAction | Self-managed open state; composes Button + shared dialog; no provider |
| Custom body | body / confirmBody for extra controls (e.g. force-delete checkbox); caller owns state in an in-tree body component |
| Labels | English defaults Confirm / Cancel; override with confirmLabel / cancelLabel |
| Tone | default or destructive for the confirm button |
| Idle cancel | useConfirm rejects with Error("Confirm cancelled"); ConfirmAction has no caller Promise |
Installing
pnpm dlx shadcn@latest add https://ui.isaacfei.com/r/confirm.jsonnpx shadcn@latest add https://ui.isaacfei.com/r/confirm.jsonyarn dlx shadcn@latest add https://ui.isaacfei.com/r/confirm.jsonbun x shadcn@latest add https://ui.isaacfei.com/r/confirm.jsonWith a namespace: npx shadcn@latest add @f-ui/confirm.
registryDependencies: shadcn alert-dialog, button. Runtime: lucide-react.
Usage
Mount the provider once near the app root for imperative confirms:
import { ConfirmProvider, useConfirm } from "@/components/f-ui/confirm/confirm-context";
function App() {
return (
<ConfirmProvider>
<YourRoutes />
</ConfirmProvider>
);
}
function DeleteButton() {
const confirm = useConfirm();
return (
<button
type="button"
onClick={() => {
void confirm({
title: "Delete this item?",
description: "This cannot be undone.",
tone: "destructive",
onConfirm: async () => {
await deleteItem();
},
}).catch((error) => {
if (error instanceof Error && error.message.includes("Confirm cancelled")) {
return;
}
// toast failure; dialog stays open for retry
});
}}
>
Delete
</button>
);
}Or use a self-contained button:
import { ConfirmAction } from "@/components/f-ui/confirm/confirm-action";
<ConfirmAction
label="Cancel task"
tone="destructive"
confirmTitle="Cancel this task?"
confirmDescription="Stops the run."
onConfirm={async () => {
await cancelTask();
}}
/>;Examples
Try each demo yourself. Expect ~1.2s of spinner + disabled Cancel/Confirm while the fake request runs.
Success — Dialog Closes
- Click Cancel task
- Click Confirm in the dialog
- Watch the spinner; when it finishes, the dialog closes and a success toast appears
"use client";
import { toast } from "sonner";
import { ConfirmAction } from "@/components/f-ui/confirm/confirm-action";
export function ConfirmActionDemo() {
return (
<ConfirmAction
label="Cancel task"
tone="destructive"
confirmTitle="Cancel this task?"
confirmDescription="Stops the run. You can start a new one later."
onConfirm={async () => {
// ~1.2s so the Confirm spinner is obvious in docs
await new Promise((r) => setTimeout(r, 1200));
toast.success("Task cancelled — dialog closed");
}}
/>
);
}Failure — Dialog Stays Open
- Click Delete (will fail)
- Click Delete anyway
- Watch the spinner; when it finishes you get an error toast and the dialog is still open — click Confirm again to retry
This is the reject contract: kit does not toast; the demo toast is host-owned. The dialog stays so the user can fix and retry.
"use client";
import { toast } from "sonner";
import { ConfirmAction } from "@/components/f-ui/confirm/confirm-action";
/**
* Always fails onConfirm — dialog must stay open so the user can retry.
* Toast is host-owned (kit never toasts on reject).
*/
export function ConfirmRejectDemo() {
return (
<ConfirmAction
label="Delete (will fail)"
tone="destructive"
confirmTitle="Delete this item?"
confirmDescription="This demo always fails. Watch: dialog stays open after the error toast."
confirmLabel="Delete anyway"
onConfirm={async () => {
await new Promise((r) => setTimeout(r, 1200));
toast.error("Server said no — dialog should still be open");
throw new Error("Server said no");
}}
/>
);
}Imperative useConfirm
Same success path via ConfirmProvider + useConfirm(). Try Cancel in the dialog: you get a “you cancelled” toast and nothing is deleted (Confirm cancelled).
- Click Delete item → Confirm → spinner → dialog closes + success toast
- Or click Delete item → Cancel → toast says you cancelled
"use client";
import { toast } from "sonner";
import {
ConfirmProvider,
useConfirm,
} from "@/components/f-ui/confirm/confirm-context";
import { Button } from "@/components/ui/button";
function ImperativeTrigger() {
const confirm = useConfirm();
return (
<Button
type="button"
variant="destructive"
onClick={() => {
void confirm({
title: "Delete this item?",
description: "This cannot be undone.",
tone: "destructive",
onConfirm: async () => {
await new Promise((r) => setTimeout(r, 1200));
toast.success("Item deleted — dialog closed");
},
}).catch((error: unknown) => {
if (
error instanceof Error &&
error.message.includes("Confirm cancelled")
) {
toast.message("You cancelled — nothing was deleted");
return;
}
toast.error(
error instanceof Error ? error.message : "Something went wrong",
);
});
}}
>
Delete item
</Button>
);
}
export function ConfirmImperativeDemo() {
return (
<ConfirmProvider>
<ImperativeTrigger />
</ConfirmProvider>
);
}Custom Body — Extra Controls
Pass body (or ConfirmAction’s confirmBody) for interactive content such as a force-delete checkbox. Keep checkbox state in an in-tree body component (or on ConfirmAction, where confirmBody is a live prop) — unlike static Ant Modal.confirm, which freezes content and needs modal.update(). Custom body is not available on row/batch confirm in this release.
- Click Delete with options
- Optionally check Force delete
- Click Confirm — toast reflects whether force was selected
"use client";
import { useRef, useState } from "react";
import { toast } from "sonner";
import {
ConfirmProvider,
useConfirm,
} from "@/components/f-ui/confirm/confirm-context";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
function ForceDeleteBody({
onForceChange,
}: {
onForceChange: (force: boolean) => void;
}) {
const [force, setForce] = useState(false);
return (
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={force}
onCheckedChange={(v) => {
const next = v === true;
setForce(next);
onForceChange(next);
}}
/>
Force delete
</label>
);
}
function BodyTrigger() {
const confirm = useConfirm();
const forceRef = useRef(false);
return (
<Button
type="button"
variant="destructive"
onClick={() => {
forceRef.current = false;
void confirm({
title: "Delete schedule?",
description: "Related runs may remain unless you force delete.",
tone: "destructive",
body: (
<ForceDeleteBody
onForceChange={(force) => {
forceRef.current = force;
}}
/>
),
onConfirm: async () => {
await new Promise((r) => setTimeout(r, 800));
toast.success(forceRef.current ? "Force deleted" : "Deleted");
},
}).catch((error: unknown) => {
if (
error instanceof Error &&
error.message.includes("Confirm cancelled")
) {
return;
}
toast.error(
error instanceof Error ? error.message : "Something went wrong",
);
});
}}
>
Delete with options
</Button>
);
}
export function ConfirmBodyDemo() {
return (
<ConfirmProvider>
<BodyTrigger />
</ConfirmProvider>
);
}Composition
ConfirmProvider
├── children
└── ConfirmDialog? (when pending)
└── AlertDialog + optional body + Confirm / Cancel
ConfirmAction
├── Button (trigger)
└── ConfirmDialog
└── AlertDialog + optional body + Confirm / CancelShared pieces: runAsyncConfirm (await + loading + close-on-success) and ConfirmDialog (plain Button confirm — not Radix AlertDialogAction, which auto-closes).
Edge Cases & Errors
| Scenario | Behavior |
|---|---|
onConfirm resolves | Dialog closes; useConfirm Promise resolves |
onConfirm rejects | Dialog stays open; loading clears; useConfirm Promise rejects once; ConfirmAction swallows reject (no caller Promise) |
| Retry after reject | Second Confirm click runs onConfirm again; it does not re-settle the already-rejected first await confirm() |
| Idle cancel / Escape / overlay | Dialog closes; useConfirm rejects with Confirm cancelled |
| Confirm while already confirming | Confirm and Cancel stay disabled; double-clicks are ignored |
useConfirm outside provider | Throws: must be used within a ConfirmProvider |
Snapshotted controlled props in body | A checked={force} node created once inside confirm({…}) does not update when parent state changes — put state inside the body component (or use ConfirmAction’s live confirmBody) |
API Reference
ConfirmOptions (useConfirm)
| Prop | Type | Default | Description |
|---|---|---|---|
title | string | — | Dialog title |
description | string | — | Optional supporting copy |
body | ReactNode | — | Optional interactive region between header and footer (checkbox, extra fields). Not a substitute for description |
tone | "default" | "destructive" | "default" | Confirm button variant |
confirmLabel | string | "Confirm" | Confirm button label |
cancelLabel | string | "Cancel" | Cancel button label |
onConfirm | () => void | Promise<void> | — | Mutation run inside the dialog |
useConfirm() returns (options: ConfirmOptions) => Promise<void>.
ConfirmAction Props
| Prop | Type | Default | Description |
|---|---|---|---|
label | string | — | Trigger button label |
icon | ReactNode | — | Optional icon before the label |
tone | "default" | "destructive" | "default" | Trigger and confirm button variant |
confirmTitle | string | — | Dialog title |
confirmDescription | string | — | Optional dialog description |
confirmBody | ReactNode | — | Optional interactive region between header and footer |
confirmLabel | string | "Confirm" | Confirm button label |
cancelLabel | string | "Cancel" | Cancel button label |
onConfirm | () => void | Promise<void> | — | Mutation run inside the dialog |
disabled | boolean | — | Disables the trigger |
className | string | — | Classes on the trigger button |
Notification Center
Bell trigger with an on-demand notification panel — unseen count, unread filter, grouped sections, inline actions with overflow, and Loading/Empty/Error states.
Comment Thread
Append-only Detail activity with newest-first flat feed, Shared / Internal visibility, and host extensibility slots.