Confirmation Dialog

A standardized way to ask for user confirmation before executing critical actions like deleting data, logging out, or making irreversible changes.

npx shadcn@latest add @shuip/confirmation-dialog
pnpm dlx shadcn@latest add @shuip/confirmation-dialog
bun x shadcn@latest add @shuip/confirmation-dialog
'use client';

import { Loader2Icon } from 'lucide-react';
import * as React from 'react';
import { Button } from '@/components/ui/button';
import {
  Dialog,
  DialogClose,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from '@/components/ui/dialog';

export interface ConfirmationDialogProps
  extends Pick<React.ComponentProps<typeof Dialog>, 'open' | 'defaultOpen' | 'onOpenChange'> {
  trigger?: React.ReactElement;
  nativeButton?: boolean;
  title?: string;
  description?: string;
  labelConfirmButton?: string;
  labelCancelButton?: string;
  children?: React.ReactNode;
  // biome-ignore lint/suspicious/noConfusingVoidType: a callback may return nothing or a promise
  onConfirm?: () => void | PromiseLike<unknown>;
}

interface ConfirmationRun {
  pending: boolean;
  onConfirm?: ConfirmationDialogProps['onConfirm'];
  setPending: (pending: boolean) => void;
  close: () => void;
}

const isPromiseLike = (value: unknown): value is PromiseLike<unknown> =>
  typeof (value as PromiseLike<unknown> | undefined)?.then === 'function';

/** Exported for the tests of the item. */
export function runConfirmation({ pending, onConfirm, setPending, close }: ConfirmationRun): void {
  if (pending) return;
  const result = onConfirm?.();
  if (!isPromiseLike(result)) {
    close();
    return;
  }
  setPending(true);
  void Promise.resolve(result)
    .then(close)
    .catch(() => {})
    .finally(() => setPending(false));
}

type DialogActionsRef = NonNullable<React.ComponentProps<typeof Dialog>['actionsRef']>;

/** Exported for the tests of the item. */
export interface ConfirmationDialogViewProps extends Omit<ConfirmationDialogProps, 'onConfirm'> {
  actionsRef: DialogActionsRef;
  pending: boolean;
  onConfirm: () => void;
}

/** Exported for the tests of the item. */
export function ConfirmationDialogView({
  trigger,
  nativeButton,
  title,
  description,
  labelConfirmButton,
  labelCancelButton,
  children,
  actionsRef,
  pending,
  onConfirm,
  ...props
}: ConfirmationDialogViewProps) {
  return (
    <Dialog actionsRef={actionsRef} {...props}>
      {trigger && <DialogTrigger render={trigger} nativeButton={nativeButton} />}
      <DialogContent>
        <DialogHeader>
          <DialogTitle>{title}</DialogTitle>
          <DialogDescription className='md:text-left'>{description}</DialogDescription>
        </DialogHeader>
        {children}
        <DialogFooter>
          {labelCancelButton && <DialogClose render={<Button variant='outline' />}>{labelCancelButton}</DialogClose>}
          <Button
            onClick={onConfirm}
            aria-disabled={pending}
            aria-busy={pending}
            className='aria-disabled:pointer-events-none aria-disabled:opacity-50'
          >
            {pending && <Loader2Icon data-icon='inline-start' className='animate-spin' aria-label='Loading' />}
            {labelConfirmButton}
          </Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
}

export function ConfirmationDialog({ onConfirm, ...props }: ConfirmationDialogProps) {
  const [pending, setPending] = React.useState(false);
  const actionsRef: DialogActionsRef = React.useRef(null);

  return (
    <ConfirmationDialogView
      {...props}
      actionsRef={actionsRef}
      pending={pending}
      onConfirm={() => runConfirmation({ pending, onConfirm, setPending, close: () => actionsRef.current?.close() })}
    />
  );
}

The command is the same for Base UI and Radix: the CLI reads the style in your components.json and installs the matching variant.

Loading...

Simple integration

<Dialog>
  <DialogTrigger render={<Button variant="destructive" />}>Delete</DialogTrigger>
  <DialogContent>
    <DialogHeader>
      <DialogTitle>Are you sure?</DialogTitle>
      <DialogDescription>
        This action cannot be undone. This will permanently delete the item.
      </DialogDescription>
    </DialogHeader>
    <DialogFooter>
      <DialogClose render={<Button variant="outline" />}>Cancel</DialogClose>
      <DialogClose render={<Button variant="destructive" />} onClick={handleDelete}>
        Delete
      </DialogClose>
    </DialogFooter>
  </DialogContent>
</Dialog>

With ConfirmationDialog, the same result in a clean, reusable component. Clicking the confirm button calls onConfirm, then closes the dialog. Set labelCancelButton to add a cancel button next to it:

<ConfirmationDialog
  trigger={<Button variant="destructive">Delete</Button>}
  title="Are you sure?"
  description="This action cannot be undone. This will permanently delete the item."
  labelConfirmButton="Delete"
  labelCancelButton="Cancel"
  onConfirm={handleDelete}
/>

Async confirmation

When onConfirm returns a promise, the dialog waits for it:

  • the confirm button is marked disabled and shows a spinner, so a second click cannot start the action twice. It stays focusable, so keyboard focus is not lost;
  • the dialog closes when the promise resolves;
  • the dialog stays open and the button is enabled again when it rejects. The rejection is not logged or rethrown by the component: show the error yourself, for example as children of the dialog, which render between the description and the buttons. Give it role="alert" so screen readers announce it.
<ConfirmationDialog
  trigger={<Button variant="destructive">Delete Account</Button>}
  title="Delete Account"
  description="This will permanently delete your account and all associated data."
  labelConfirmButton="Delete Account"
  labelCancelButton="Cancel"
  onConfirm={() => deleteAccount()}
/>

A synchronous onConfirm closes the dialog right after it returns.

Cancel and Escape do not stop the work: they close the dialog, the promise keeps running, and the confirm button stays pending, also when the dialog is reopened, until the promise settles. The dialog then closes if it resolves.

The dialog closes through the Base UI actionsRef, so every close that the confirm button causes, after a synchronous or an asynchronous onConfirm, reaches onOpenChange with the imperative-action reason.

Common use cases

Delete confirmation

<ConfirmationDialog
  trigger={<Button variant="destructive">Delete Account</Button>}
  title="Delete Account"
  description="This will permanently delete your account and all associated data. This action cannot be undone."
  labelConfirmButton="Delete Account"
  labelCancelButton="Cancel"
  onConfirm={() => deleteAccount()}
/>

Save confirmation

<ConfirmationDialog
  trigger={<Button>Save Changes</Button>}
  title="Save Changes"
  description="Are you sure you want to save these changes?"
  labelConfirmButton="Save"
  onConfirm={() => saveChanges()}
/>

Custom trigger with icon

<ConfirmationDialog
  trigger={
    <Button variant="ghost" size="icon">
      <Trash2 className="size-4" />
    </Button>
  }
  title="Delete Item"
  description="This item will be permanently removed."
  labelConfirmButton="Delete"
  onConfirm={() => deleteItem(id)}
/>

Controlled dialog

Pass open and onOpenChange to open the dialog from your own state, without a trigger. The dialog still closes after onConfirm, and when an async onConfirm resolves, by calling onOpenChange(false):

const [open, setOpen] = useState(false);

<ConfirmationDialog
  open={open}
  onOpenChange={setOpen}
  title="Delete Item"
  description="This item will be permanently removed."
  labelConfirmButton="Delete"
  onConfirm={() => deleteItem(id)}
/>

Examples

Async Error

Loading...
'use client';

import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { ConfirmationDialog } from '@/components/ui/shuip/confirmation-dialog';

const saveChanges = () =>
  new Promise<void>((_, reject) => setTimeout(() => reject(new Error('The server is unreachable.')), 1500));

export default function ConfirmationDialogAsyncErrorExample() {
  const [error, setError] = useState<string>();

  return (
    <ConfirmationDialog
      trigger={<Button>Save changes</Button>}
      title='Save changes'
      description='Are you sure you want to save these changes?'
      labelConfirmButton='Save'
      labelCancelButton='Cancel'
      onOpenChange={() => setError(undefined)}
      onConfirm={async () => {
        setError(undefined);
        try {
          await saveChanges();
        } catch (e) {
          setError(e instanceof Error ? e.message : 'Something went wrong.');
          throw e;
        }
      }}
    >
      {error && (
        <p role='alert' className='text-sm text-destructive'>
          {error}
        </p>
      )}
    </ConfirmationDialog>
  );
}

Async

Loading...
'use client';

import { Button } from '@/components/ui/button';
import { ConfirmationDialog } from '@/components/ui/shuip/confirmation-dialog';

const deleteAccount = () => new Promise<void>((resolve) => setTimeout(resolve, 1500));

export default function ConfirmationDialogAsyncExample() {
  return (
    <ConfirmationDialog
      trigger={<Button variant='destructive'>Delete account</Button>}
      title='Delete account'
      description='This will permanently delete your account and all associated data.'
      labelConfirmButton='Delete account'
      labelCancelButton='Cancel'
      onConfirm={deleteAccount}
    />
  );
}

Default

Loading...
import { Button } from '@/components/ui/button';
import { ConfirmationDialog } from '@/components/ui/shuip/confirmation-dialog';

export default function ConfirmationDialogExample() {
  return (
    <ConfirmationDialog
      trigger={<Button>Open confirmation dialog</Button>}
      title='Confirmation Dialog'
      description='Are you sure you want to make this action?'
      labelConfirmButton='Make it'
      onConfirm={() => alert('Confirmed')}
    />
  );
}

Props

Prop

Type

On this page