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.
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
childrenof the dialog, which render between the description and the buttons. Give itrole="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
'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
'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
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