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.jsonnpx shadcn-svelte@latest add https://coral.imlargo.dev/r/kit-confirm-dialog.jsonbunx --bun shadcn-svelte@latest add https://coral.imlargo.dev/r/kit-confirm-dialog.jsonyarn dlx shadcn-svelte@latest add https://coral.imlargo.dev/r/kit-confirm-dialog.jsonWritten 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
<script lang="ts">
import ConfirmDialog from '#lib/components/coral/kit/confirm-dialog/confirm-dialog.svelte';
import { Button } from '#lib/components/ui/button/index.js';
let revoked = $state(0);
</script>
<div class="flex flex-col items-center gap-3">
<ConfirmDialog
title="Revoke this API key?"
description="Requests signed with it start failing immediately. You can issue a new one."
confirmLabel="Revoke"
cancelLabel="Cancel"
variant="destructive"
onconfirm={() => revoked++}
>
{#snippet trigger({ props })}
<Button {...props} variant="outline">Revoke key</Button>
{/snippet}
</ConfirmDialog>
<p class="text-sm text-muted-foreground">Revoked: {revoked}</p>
</div>What Coral adds
- The right primitive:
alert-dialog. Outside clicks are ignored, focus is trapped, the role isalertdialogand the title and description are wired to it. - It waits. Hand it an async
onconfirmand 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
<script lang="ts">
import ConfirmDialog from '#lib/components/coral/kit/confirm-dialog/confirm-dialog.svelte';
import { Button } from '#lib/components/ui/button/index.js';
let open = $state(false);
let log = $state<string[]>([]);
// Fails the first time, succeeds after that - the shape of a real request.
let attempts = $state(0);
async function save() {
attempts++;
await new Promise((r) => setTimeout(r, 900));
if (attempts === 1) {
log = [...log, 'error: a build is still running'];
throw new Error('build running');
}
log = [...log, 'deleted'];
}
</script>
<div class="flex flex-col items-center gap-3">
<Button variant="outline" onclick={() => (open = true)}>Try it (fails the first time)</Button>
<ConfirmDialog
bind:open
title="Delete this deployment?"
description="It can't be deleted while a build is still running."
confirmLabel="Delete"
cancelLabel="Cancel"
variant="destructive"
onconfirm={save}
/>
<ul class="text-sm text-muted-foreground">
{#each log as entry, i (i)}
<li>{entry}</li>
{/each}
</ul>
</div>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
triggersnippet when the dialog belongs to a button that is right there. Spreadpropsonto whatever you render. bind:openwhen 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.
<script lang="ts">
import ConfirmDialog from '#lib/components/coral/kit/confirm-dialog/confirm-dialog.svelte';
import { Button } from '#lib/components/ui/button/index.js';
const affected = [
'#412 Flaky test on CI',
'#418 Webhook retries never stop',
'#423 Stale cache after deploy'
];
</script>
<ConfirmDialog
title="Close 3 issues?"
description="This action cannot be undone."
confirmLabel="Close issues"
cancelLabel="Cancel"
variant="destructive"
>
{#snippet trigger({ props })}
<Button {...props} variant="outline">Close selected</Button>
{/snippet}
<!-- Anything between the description and the buttons: what exactly is affected. -->
<ul class="list-inside list-disc text-sm text-muted-foreground">
{#each affected as item (item)}
<li>{item}</li>
{/each}
</ul>
</ConfirmDialog>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.
<script lang="ts">
import ConfirmDialog from '#lib/components/coral/kit/confirm-dialog/confirm-dialog.svelte';
import { Button } from '#lib/components/ui/button/index.js';
</script>
<ConfirmDialog
title="Permissions updated"
description="Your role on this project changed. Some sections may look different."
confirmLabel="Got it"
showCancel={false}
>
{#snippet trigger({ props })}
<Button {...props} variant="outline">View notice</Button>
{/snippet}
</ConfirmDialog>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:
| Prop | Type | Default | Description |
|---|---|---|---|
title | string | - | The question. Required. |
description | string | - | 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. |
confirmLabel | string | Continue | Label for the button that goes ahead. |
cancelLabel | string | Cancel | Label for the button that backs out. |
showCancel | boolean | true | Set false for acknowledge-only. |
variant | ButtonVariant | 'default' | Confirm button variant. destructive for anything irreversible. |
onerror | (error: unknown) => void | - | What onconfirm threw. It stays open either way. |
pending | boolean | - | Drive the busy state yourself. Otherwise it tracks onconfirm. |
open | boolean | false | Bindable. |
size | 'default' | 'sm' | 'default' | Forwarded to the content. |
class | string | - | Merged onto the content. |
trigger | Snippet<[{ props }]> | - | The element that opens it. |
children | Snippet | - | 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.