Skip to main content
Search
Search the documentation

Confirm dialog

Are you sure? - with the request it guards, and what happens when that request fails.

pnpm dlx shadcn-svelte@latest add https://coral.imlargo.dev/r/kit-confirm-dialog.json

Written by hand, this is an AlertDialog, a Spinner, an isSaving flag and a try/catch at every call site that guards a destructive request. Extracting it over Dialog is the other common move, and Dialog is the wrong primitive: it closes when you click outside, so a destructive confirmation is one stray click away from being dismissed, and it has nothing that waits for a request.

Revoked: 0

What Coral adds

  • The right primitive: alert-dialog. Outside clicks are ignored, focus is trapped, the role is alertdialog and the title and description are wired to it.
  • It waits. Hand it an async onconfirm and the dialog stays put until the promise settles.
  • It survives failure. A rejected promise leaves the dialog open, so the error has somewhere to be reported and the reader can try again.
  • One click, one write: a second click while the first request is in flight does nothing.
  • Escape stops meaning "cancel" while a request is on its way to the server.

Waiting, and failing

That demo fails the first time and succeeds after. Nothing closes on the failure: the buttons come back, the reader reads the error, and pressing the button again retries.

The rule is one line: return false, or throw, to keep it open.

<ConfirmDialog
	title="Delete {deployment.name}?"
	description="It can't be deleted while a build is still running."
	variant="destructive"
	confirmLabel="Delete"
	onconfirm={async () => {
		try {
			await deployments.remove(deployment.id);
			toast.success(`${deployment.name} was deleted.`);
		} catch (err) {
			toast.error(normalizeError(err).message);
			return false; // stays open, so they can retry
		}
	}}
/>

Catching inside onconfirm is the shape to aim for: it puts the message where the reader is looking. Letting the error propagate instead also keeps the dialog open, but it surfaces as an unhandled rejection rather than as something a person can read.

While the promise is pending both buttons are disabled, the confirm button carries a spinner, and Escape is ignored. Letting Escape through would read as cancelled for a delete already on its way to the server.

Opening it

Two ways, and the choice is usually made for you:

  • A trigger snippet when the dialog belongs to a button that is right there. Spread props onto whatever you render.
  • bind:open when the dialog belongs to a row menu, a table action, or anything that decides to ask after something else was clicked. This is the common case in a list screen.

Extra detail in the body

Anything between the description and the buttons: what exactly is about to change.

Acknowledge-only

showCancel={false} leaves a dialog that can only be agreed with. Escape still closes it, so it is a nudge and not a trap.

Import

<script lang="ts">
	import ConfirmDialog from '#lib/components/coral/kit/confirm-dialog/confirm-dialog.svelte';
</script>

Props

Everything the shadcn alert-dialog root accepts stays available: open, onOpenChange. On top of that:

PropTypeDefaultDescription
titlestring-The question. Required.
descriptionstring-What changes, and whether it can be undone.
onconfirm() => unknown-Runs on confirm. Return false or throw to keep it open.
oncancel() => void-Runs on cancel button or Escape. Never on confirm.
confirmLabelstringContinueLabel for the button that goes ahead.
cancelLabelstringCancelLabel for the button that backs out.
showCancelbooleantrueSet false for acknowledge-only.
variantButtonVariant'default'Confirm button variant. destructive for anything irreversible.
onerror(error: unknown) => void-What onconfirm threw. It stays open either way.
pendingboolean-Drive the busy state yourself. Otherwise it tracks onconfirm.
openbooleanfalseBindable.
size'default' | 'sm''default'Forwarded to the content.
classstring-Merged onto the content.
triggerSnippet<[{ props }]>-The element that opens it.
childrenSnippet-Extra content between description and buttons.

Accessibility

role="alertdialog", aria-modal, and aria-labelledby / aria-describedby pointing at the title and description, all from the primitive, all verified. Focus is trapped: Tab cycles inside the dialog and cannot reach the page behind it. Outside clicks are ignored by design, which is the difference between alert-dialog and dialog.

The cancel button is rendered through the primitive's child snippet for one reason: passed disabled directly, the primitive keeps the flag for its own close handler and never puts it on the element, leaving a button that looks pressable and does nothing. Going through child puts the attribute where a keyboard and a screen reader can both see it.

Why flat props

There is a canonical case, and nearly every call site is exactly it: a question, a consequence, two buttons. What varies (the trigger, extra body detail) is a snippet, so the conventions are satisfied without a prop for every variation.

The one thing deliberately not here is a promise-returning confirm() helper you could await in an event handler. It reads well in a single call site and badly everywhere else: the dialog has to be mounted, so a list screen ends up with one mounted per row.