Confirm Dialog
A modal confirmation with typed-phrase protection, pending and error states, built on native <dialog>.
npx shadcn@latest add https://sundaeui.dev/r/confirm-dialog.json'use client'
import { useEffect, useId, useRef, useState } from 'react'
export interface ConfirmDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
title: string
description: React.ReactNode
confirmLabel?: string
cancelLabel?: string
/** Require typing this text before the destructive action enables. */
confirmText?: string
tone?: 'danger' | 'default'
/** Return a promise to show a pending state; throw to show an error. */
onConfirm: () => void | Promise<void>
}
/**
* Modal confirmation built on <dialog>. It scales out of the page, traps focus
* natively, and can require typing a phrase before a destructive action.
*/
export function ConfirmDialog({ open, onOpenChange, title, description, confirmLabel = 'Confirm', cancelLabel = 'Cancel', confirmText, tone = 'danger', onConfirm }: ConfirmDialogProps) {
const ref = useRef<HTMLDialogElement>(null)
const id = useId()
const [typed, setTyped] = useState('')
const [pending, setPending] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
const d = ref.current
if (!d) return
if (open && !d.open) {
setTyped('')
setError('')
d.showModal()
} else if (!open && d.open) d.close()
}, [open])
const ready = !confirmText || typed === confirmText
const go = async () => {
if (!ready || pending) return
setPending(true)
setError('')
try {
await onConfirm()
onOpenChange(false)
} catch (e) {
setError(e instanceof Error ? e.message : 'Something went wrong.')
} finally {
setPending(false)
}
}
return (
<dialog
ref={ref}
aria-labelledby={`${id}-t`}
aria-describedby={`${id}-d`}
onClose={() => onOpenChange(false)}
onCancel={(e) => pending && e.preventDefault()}
onClick={(e) => e.target === ref.current && !pending && onOpenChange(false)}
className="m-auto w-[min(440px,calc(100vw-32px))] rounded-2xl border border-zinc-200 bg-white p-0 text-zinc-900 shadow-[0_30px_80px_-20px_rgb(0_0_0/0.4)] backdrop:bg-zinc-950/40 backdrop:backdrop-blur-[2px] open:motion-safe:animate-[cd-in_260ms_cubic-bezier(.22,1,.36,1)] dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-100 [&::backdrop]:motion-safe:animate-[cd-fade_260ms_ease]"
>
<div className="p-6">
{tone === 'danger' && (
<div aria-hidden className="mb-4 grid size-10 place-items-center rounded-full bg-red-50 text-red-600 dark:bg-red-500/10 dark:text-red-400">
<svg viewBox="0 0 20 20" className="size-5" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round">
<path d="M10 6.5v4.5M10 14v.2M8.6 2.9 1.8 14.8A1.6 1.6 0 0 0 3.2 17h13.6a1.6 1.6 0 0 0 1.4-2.2L11.4 2.9a1.6 1.6 0 0 0-2.8 0Z" />
</svg>
</div>
)}
<h2 id={`${id}-t`} className="text-[17px] font-semibold tracking-tight">
{title}
</h2>
<div id={`${id}-d`} className="mt-2 text-[14px] leading-relaxed text-zinc-600 dark:text-zinc-400">
{description}
</div>
{confirmText && (
<label className="mt-5 block text-[13px] text-zinc-600 dark:text-zinc-400">
Type <code className="rounded bg-zinc-100 px-1 py-0.5 font-mono text-[12.5px] text-zinc-900 dark:bg-zinc-800 dark:text-zinc-100">{confirmText}</code> to confirm
<input
autoFocus
value={typed}
onChange={(e) => setTyped(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && go()}
autoComplete="off"
spellCheck={false}
className="mt-2 h-10 w-full rounded-lg border border-zinc-200 bg-transparent px-3 font-mono text-[14px] text-zinc-900 outline-none focus:border-zinc-900 focus:ring-4 focus:ring-zinc-900/5 dark:border-zinc-800 dark:text-zinc-100"
/>
</label>
)}
{error && (
<p role="alert" className="mt-3 text-[13px] text-red-600 dark:text-red-400">
{error}
</p>
)}
</div>
<div className="flex flex-col-reverse gap-2 border-t border-zinc-100 bg-zinc-50/60 px-6 py-4 sm:flex-row sm:justify-end dark:border-zinc-900 dark:bg-zinc-900/40">
<button type="button" autoFocus={!confirmText} disabled={pending} onClick={() => onOpenChange(false)} className="h-9 rounded-lg border border-zinc-200 bg-white px-4 text-[13.5px] font-medium hover:bg-zinc-50 disabled:opacity-50 dark:border-zinc-800 dark:bg-zinc-950 dark:hover:bg-zinc-900">
{cancelLabel}
</button>
<button
type="button"
disabled={!ready || pending}
onClick={go}
className={`inline-flex h-9 items-center justify-center gap-2 rounded-lg px-4 text-[13.5px] font-medium text-white transition disabled:cursor-not-allowed disabled:opacity-40 ${tone === 'danger' ? 'bg-red-600 hover:bg-red-700' : 'bg-zinc-900 hover:bg-zinc-800 dark:bg-white dark:text-zinc-900'}`}
>
{pending && <span aria-hidden className="size-3.5 animate-spin rounded-full border-2 border-white/30 border-t-white" />}
{confirmLabel}
</button>
</div>
<style>{`@keyframes cd-in{from{opacity:0;transform:translateY(8px) scale(.97)}}@keyframes cd-fade{from{opacity:0}}`}</style>
</dialog>
)
}Overview
For actions that can’t be undone. The dialog rises slightly into place over a softly blurred page and stays on screen until the decision is made. Optionally, the destructive button only enables once the person types an exact phrase, such as the workspace name, so a misclick can’t delete production.
onConfirm can be async. The button shows a spinner and the dialog can’t be dismissed mid-request. If it throws, the error appears inline and the dialog stays open, so the person can try again or cancel. Cancel is focused by default when no phrase is required.
Use it for deletions, revoking access and billing changes. Don’t use it for reversible actions: an undo toast is kinder.
When to use
- A single interaction you want to feel finished, dropped into an existing screen.
When not to use
- A whole page layout: look at Sections or Templates.
Installation
pnpm dlx shadcn@latest add https://sundaeui.dev/r/confirm-dialog.jsonnpx shadcn@latest add https://sundaeui.dev/r/confirm-dialog.jsonyarn dlx shadcn@latest add https://sundaeui.dev/r/confirm-dialog.jsonbunx --bun shadcn@latest add https://sundaeui.dev/r/confirm-dialog.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy confirm-dialog.tsx into components/sundae/.
- Make sure Tailwind scans that folder (v4 detects it automatically; v3 needs it in `content`).
- Import the component and use it as shown below.
Usage
import { ConfirmDialog } from '@/components/sundae/confirm-dialog'
<ConfirmDialog
open={open}
onOpenChange={setOpen}
title="Delete “acme-prod”?"
description="This permanently deletes the workspace."
confirmText="acme-prod"
confirmLabel="Delete workspace"
onConfirm={() => deleteWorkspace(id)}
/>Props
| Prop | Type | Default |
|---|---|---|
open / onOpenChange*Controlled visibility. | boolean / (open) => void | — |
title*Dialog heading (aria-labelledby). | string | — |
description*Consequences, in plain words (aria-describedby). | ReactNode | — |
confirmTextPhrase that must be typed exactly to enable confirm. | string | — |
toneDanger adds the warning icon and red action. | 'danger' | 'default' | 'danger' |
onConfirm*Async aware. Throw to show an inline error. | () => void | Promise<void> | — |
confirmLabel / cancelLabelButton text. Name the action, not “OK”. | string | — |
Accessibility
- A native <dialog> opened with showModal(): focus is trapped, the page behind is inert, and focus returns when it closes.
- Labelled and described by its title and description. Errors use role="alert".
- Escape and backdrop clicks are ignored while a request is pending.
Customization
- Pass tone="default" for non-destructive confirmations.
- Restyle the backdrop through the backdrop: utilities.
FAQ
- Is Confirm Dialog free?
- Yes. Confirm Dialog is free for personal and commercial projects, with full source on this page.
- How do I install Confirm Dialog?
- Run npx shadcn@latest add https://sundaeui.dev/r/confirm-dialog.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Confirm Dialog support keyboard and screen readers?
- A native <dialog> opened with showModal(): focus is trapped, the page behind is inert, and focus returns when it closes. Labelled and described by its title and description. Errors use role="alert".
- Does Confirm Dialog respect reduced motion?
- Yes. With prefers-reduced-motion set, decorative motion is removed and state changes are instant.
More like this
All components →Fold Accordion
Components · Understated- Pro
Sheet Drawer
Components · Refined - Pro
Team Members
Components · Understated - Pro
AI Composer
Components · Understated Avatar Stack
Components · Refined- Pro
Notification Center
Components · Refined