Confirm
Async confirmation via ConfirmProvider, useConfirm, ConfirmAction, and Popconfirm with await, loading, and close-on-success.
Confirm runs a destructive or consequential action inside confirmation chrome: await onConfirm, show loading on the confirm button, close on success, and keep the surface open on failure. It follows Ant Design Modal.confirm action-in-dialog semantics (Promise onOk), not a boolean-only “decide then mutate outside” API. Popconfirm uses the same async contract in an anchored popover (Ant Popconfirm).
Use ConfirmProvider + useConfirm() for imperative calls from handlers (toolbar, menu, command palette). Use ConfirmAction when a single button should own its own confirm surface — no provider required. Use Popconfirm (or ConfirmAction with chrome="popover") for short, trigger-anchored confirms.
When To Use
- Confirm a mutation before it runs (delete, cancel, revoke) and keep loading inside the confirm surface until the promise settles.
- Prefer
Popconfirm(orConfirmActionchrome="popover") for inline / anchored row or toolbar actions with short copy. Table visible row triggers (inline and trailing) default to Popconfirm; setconfirm.chrome: "dialog"when the confirm must stay centered. - Prefer dialog chrome (
useConfirm,ConfirmActiondefault, overflow menu, batch, or longconfirmBody) for long copy, batch actions, imperative call sites, extra controls, or row overflow (⋮). ConfirmActiondefaults to dialog; visible rowconfirmdefaults to popover. Same"dialog" | "popover"pair, inverted default.- Need an in-dialog acknowledgement or short reason before Confirm can run →
confirmDisabledplusbodyon dialog chrome (ConfirmActiondefault). Do not put extra controls on Popconfirm. - 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. - Table row
confirmshares the same async contract; prefer kit row confirm wiring there instead of hand-rolling a second surface. Visible triggers default Popconfirm; overflow and batch stay Dialog.
Features
| Area | Behavior |
|---|---|
| Action-in-dialog | onConfirm returns void | boolean | Promise<void | boolean>; non-false resolve closes; false or 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 + dialog or Popconfirm; no provider |
| Popconfirm | Anchored popover confirm; same await / loading / close-on-success contract as dialog |
Row confirm.chrome | Visible inline / trailing triggers: "popover" (default) or "dialog". Overflow always Dialog; explicit chrome: "popover" warns in development. ConfirmAction.chrome still defaults to "dialog". |
| Custom body | body / confirmBody for extra controls (e.g. force-delete checkbox); caller owns state in an in-tree body component; dialog chrome only |
| Confirm gate | confirmDisabled disables the Confirm button only. Cancel stays enabled while idle. Trigger disabled still means do not open. |
| Keep open | onConfirm may return false (or Promise<false>) to stay open without throwing. Reject / throw remains the failure path. |
| 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 / Popconfirm have 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, shadcn button (AlertDialog / shadcn Cancel primitives), shadcn popover (Popconfirm), and Button via https://ui.isaacfei.com/r/button.json (ConfirmAction trigger and ConfirmDialog / Popconfirm loading).
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();
}}
/>;Anchored short confirms:
import { Popconfirm } from "@/components/f-ui/confirm/popconfirm";
import { Button } from "@/components/f-ui/button/button";
<Popconfirm
title="Delete this row?"
tone="destructive"
confirmLabel="Delete"
onConfirm={async () => {
await deleteRow();
}}
>
<Button type="button" variant="destructive">
Delete
</Button>
</Popconfirm>;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 Popconfirm or 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>
);
}Gate Confirm Until Acknowledged
Pass confirmDisabled from in-dialog state so Confirm stays inert until the operator checks the acknowledgement. Cancel stays clickable.
- Click Submit order
- Confirm is disabled until you check the box
- Check the box → Submit → spinner → dialog closes
"use client";
import { useState } from "react";
import { toast } from "sonner";
import { ConfirmAction } from "@/components/f-ui/confirm/confirm-action";
import { Checkbox } from "@/components/ui/checkbox";
export function ConfirmGatedDemo() {
const [acknowledged, setAcknowledged] = useState(false);
return (
<ConfirmAction
label="Submit order"
confirmTitle="Submit this order?"
confirmDescription="Quantities cannot be changed after submit."
confirmBody={
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={acknowledged}
onCheckedChange={(v) => setAcknowledged(v === true)}
/>
I confirm the quantities above are final
</label>
}
confirmDisabled={!acknowledged}
confirmLabel="Submit"
onConfirm={async () => {
await new Promise((r) => setTimeout(r, 800));
setAcknowledged(false);
toast.success("Order submitted");
}}
/>
);
}Keep Open Without Throwing
Return false from onConfirm when validation fails (empty reason). The dialog stays open and loading clears — this is not an error, so there is no failure toast. Throw is still the path for a failed mutation (see Failure above).
- Click Unlock fields
- Click Unlock with an empty reason — dialog stays open
- Type a reason → Unlock → dialog closes
"use client";
import { useState } from "react";
import { toast } from "sonner";
import { ConfirmAction } from "@/components/f-ui/confirm/confirm-action";
import { Input } from "@/components/ui/input";
export function ConfirmKeepOpenDemo() {
const [reason, setReason] = useState("");
return (
<ConfirmAction
label="Unlock fields"
confirmTitle="Unlock these fields?"
confirmDescription="Enter a reason so the change is auditable."
confirmBody={
<Input
id="unlock-reason"
aria-label="Unlock reason"
placeholder="Why are you unlocking"
value={reason}
onChange={(e) => setReason(e.target.value)}
/>
}
confirmLabel="Unlock"
onConfirm={async () => {
if (!reason.trim()) {
return false;
}
await new Promise((r) => setTimeout(r, 800));
setReason("");
toast.success("Fields unlocked");
}}
/>
);
}Popconfirm — Anchored Confirm
Short copy next to the trigger. Same await / loading / close-on-success contract as the dialog path.
- Click Delete row
- Click Delete in the popover
- Watch the spinner; when it finishes, the popover closes and a success toast appears
"use client";
import { toast } from "sonner";
import { Button } from "@/components/f-ui/button/button";
import { Popconfirm } from "@/components/f-ui/confirm/popconfirm";
export function ConfirmPopconfirmDemo() {
return (
<Popconfirm
title="Delete this row?"
description="You can undo from the trash for 30 days."
tone="destructive"
confirmLabel="Delete"
onConfirm={async () => {
await new Promise((r) => setTimeout(r, 1200));
toast.success("Deleted");
}}
>
<Button type="button" variant="destructive">
Delete row
</Button>
</Popconfirm>
);
}ConfirmAction — Popover Chrome
ConfirmAction with chrome="popover" wraps the same Popconfirm surface — useful when you want the stock button + labels API without composing Popconfirm yourself.
- Click Delete row
- Click Delete in the popover
- Spinner → popover closes + success toast
"use client";
import { toast } from "sonner";
import { ConfirmAction } from "@/components/f-ui/confirm/confirm-action";
export function ConfirmActionPopoverDemo() {
return (
<ConfirmAction
label="Delete row"
tone="destructive"
chrome="popover"
confirmTitle="Delete this row?"
confirmDescription="You can undo from the trash for 30 days."
confirmLabel="Delete"
onConfirm={async () => {
await new Promise((r) => setTimeout(r, 1200));
toast.success("Deleted");
}}
/>
);
}Composition
ConfirmProvider
├── children
└── ConfirmDialog? (when pending)
└── AlertDialog + optional body + Confirm / Cancel
ConfirmAction (chrome="dialog", default)
├── Button (trigger)
└── ConfirmDialog
└── AlertDialog + optional body + Confirm / Cancel
ConfirmAction (chrome="popover")
└── Popconfirm
├── Button (trigger)
└── Popover + Confirm / Cancel
Popconfirm
├── children (trigger)
└── Popover + Confirm / CancelShared pieces: runAsyncConfirm (await + loading + close-on-success), ConfirmDialog (plain Button confirm — not Radix AlertDialogAction, which auto-closes), and Popconfirm (same contract over Popover).
Edge Cases & Errors
| Scenario | Behavior |
|---|---|
onConfirm resolves (not false) | Surface closes; useConfirm Promise resolves |
onConfirm returns false | Surface stays open; loading clears; not an error. useConfirm() Promise stays pending until success or idle cancel |
onConfirm rejects | Surface stays open; loading clears; useConfirm Promise rejects once; ConfirmAction / Popconfirm swallow 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 | Surface 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 controlled checkbox (e.g. checked bound to parent state) created once inside confirm(…) does not update when parent state changes — put state inside the body component (or use ConfirmAction’s live confirmBody) |
confirmBody with chrome="popover" | Ignored. Development warns once. Use chrome="dialog" for extra controls |
Row overflow confirm.chrome: "popover" | Ignored. Overflow always Dialog. Development warns. Omit chrome for silent Dialog |
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 | boolean | Promise<void | boolean> | — | Mutation run inside the dialog. Return false to keep open without throwing |
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 |
chrome | "dialog" | "popover" | "dialog" | Dialog vs Popconfirm surface |
confirmTitle | string | — | Confirm title |
confirmDescription | string | — | Optional supporting copy |
confirmBody | ReactNode | — | Optional interactive region (dialog chrome only; ignored for popover) |
confirmLabel | string | "Confirm" | Confirm button label |
cancelLabel | string | "Cancel" | Cancel button label |
onConfirm | () => void | boolean | Promise<void | boolean> | — | Mutation run inside the confirm surface. Return false to keep open without throwing |
disabled | boolean | — | Disables the trigger |
confirmDisabled | boolean | — | Disables the Confirm button. Distinct from disabled (trigger) |
className | string | — | Classes on the trigger button |
Popconfirm Props
| Prop | Type | Default | Description |
|---|---|---|---|
title | string | — | Popover title |
description | string | — | Optional supporting copy |
tone | "default" | "destructive" | "default" | Confirm button variant |
confirmLabel | string | "Confirm" | Confirm button label |
cancelLabel | string | "Cancel" | Cancel button label |
onConfirm | () => void | boolean | Promise<void | boolean> | — | Mutation run inside the popover. Return false to keep open without throwing |
disabled | boolean | false | Disables opening |
confirmDisabled | boolean | — | Disables the Confirm button. Distinct from disabled (do not open) |
side | "top" | "right" | "bottom" | "left" | "top" | Popover side |
align | "start" | "center" | "end" | "center" | Popover alignment |
open | boolean | — | Controlled open state |
onOpenChange | (open: boolean) => void | — | Controlled open change |
children | ReactElement | — | Trigger element |
className | string | — | Classes on the popover content |