f-ui
Components

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 useConfirm when many call sites share one portal under a root ConfirmProvider.
  • Prefer ConfirmAction for a detail/toolbar button that always confirms the same operation.
  • Do not treat idle cancel as success — useConfirm rejects with Confirm cancelled when 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

AreaBehavior
Action-in-dialogonConfirm returns void | Promise<void>; resolve closes; reject keeps open and clears loading
Imperative APIConfirmProvider + useConfirm() — kit owns the AlertDialog portal (Ant App / useModal class)
ConfirmActionSelf-managed open state; composes Button + shared dialog; no provider
Custom bodybody / confirmBody for extra controls (e.g. force-delete checkbox); caller owns state in an in-tree body component
LabelsEnglish defaults Confirm / Cancel; override with confirmLabel / cancelLabel
Tonedefault or destructive for the confirm button
Idle canceluseConfirm rejects with Error("Confirm cancelled"); ConfirmAction has no caller Promise

Installing

pnpm dlx shadcn@latest add https://ui.isaacfei.com/r/confirm.json
npx shadcn@latest add https://ui.isaacfei.com/r/confirm.json
yarn dlx shadcn@latest add https://ui.isaacfei.com/r/confirm.json
bun x shadcn@latest add https://ui.isaacfei.com/r/confirm.json

With 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

  1. Click Cancel task
  2. Click Confirm in the dialog
  3. 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

  1. Click Delete (will fail)
  2. Click Delete anyway
  3. 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).

  1. Click Delete itemConfirm → spinner → dialog closes + success toast
  2. Or click Delete itemCancel → 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.

  1. Click Delete with options
  2. Optionally check Force delete
  3. 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 / Cancel

Shared pieces: runAsyncConfirm (await + loading + close-on-success) and ConfirmDialog (plain Button confirm — not Radix AlertDialogAction, which auto-closes).

Edge Cases & Errors

ScenarioBehavior
onConfirm resolvesDialog closes; useConfirm Promise resolves
onConfirm rejectsDialog stays open; loading clears; useConfirm Promise rejects once; ConfirmAction swallows reject (no caller Promise)
Retry after rejectSecond Confirm click runs onConfirm again; it does not re-settle the already-rejected first await confirm()
Idle cancel / Escape / overlayDialog closes; useConfirm rejects with Confirm cancelled
Confirm while already confirmingConfirm and Cancel stay disabled; double-clicks are ignored
useConfirm outside providerThrows: must be used within a ConfirmProvider
Snapshotted controlled props in bodyA 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)

PropTypeDefaultDescription
titlestringDialog title
descriptionstringOptional supporting copy
bodyReactNodeOptional interactive region between header and footer (checkbox, extra fields). Not a substitute for description
tone"default" | "destructive""default"Confirm button variant
confirmLabelstring"Confirm"Confirm button label
cancelLabelstring"Cancel"Cancel button label
onConfirm() => void | Promise<void>Mutation run inside the dialog

useConfirm() returns (options: ConfirmOptions) => Promise<void>.

ConfirmAction Props

PropTypeDefaultDescription
labelstringTrigger button label
iconReactNodeOptional icon before the label
tone"default" | "destructive""default"Trigger and confirm button variant
confirmTitlestringDialog title
confirmDescriptionstringOptional dialog description
confirmBodyReactNodeOptional interactive region between header and footer
confirmLabelstring"Confirm"Confirm button label
cancelLabelstring"Cancel"Cancel button label
onConfirm() => void | Promise<void>Mutation run inside the dialog
disabledbooleanDisables the trigger
classNamestringClasses on the trigger button

On this page