Confirm dialog
A controlled yes-or-no dialog. Use it directly, or await it through useConfirm.
<script setup lang="ts">
import ConfirmDialog from '@shared/components/feedback/ConfirmDialog.vue'
import { Button } from '@shared/components/ui/button'
import { ref } from 'vue'
const open = ref(false)
const result = ref('')
function onConfirm(): void {
open.value = false
result.value = 'Published'
}
function onCancel(): void {
open.value = false
result.value = 'Kept as a draft'
}
</script>
<template>
<div class="space-y-3">
<Button @click="open = true">
Publish the article
</Button>
<ConfirmDialog
v-model:open="open"
title="Publish this article?"
description="It becomes visible to everyone with the link."
confirm-label="Publish"
@confirm="onConfirm"
@cancel="onCancel"
/>
<p class="text-sm text-muted-foreground">
{{ result || 'Nothing decided yet' }}
</p>
</div>
</template>Usage
The dialog is fully controlled. You own open, and the buttons emit confirm and cancel rather than closing themselves. update:open with false covers Escape and an overlay press, which you should treat as a cancel.
The buttons are plain buttons, not the auto-closing action and cancel from the dialog primitive. Those race their close event ahead of the click handler, which is how a confirm gets reported as a cancel.
confirmLabel and cancelLabel default to translation keys, so they follow the language switcher. A string you pass wins.
Examples
Destructive
variant="destructive" draws the confirm button in the destructive color. It changes nothing else, so the words still have to say what will happen.
<script setup lang="ts">
import ConfirmDialog from '@shared/components/feedback/ConfirmDialog.vue'
import { Button } from '@shared/components/ui/button'
import { ref } from 'vue'
const open = ref(false)
const removed = ref(false)
</script>
<template>
<div class="space-y-3">
<Button variant="destructive" :disabled="removed" @click="open = true">
Remove the webhook
</Button>
<ConfirmDialog
v-model:open="open"
variant="destructive"
title="Remove this webhook?"
description="Events stop being delivered straight away. You can add it again later."
confirm-label="Remove"
@confirm="open = false; removed = true"
@cancel="open = false"
/>
<p class="text-sm text-muted-foreground">
{{ removed ? 'The webhook was removed' : 'The webhook is active' }}
</p>
</div>
</template>Through useConfirm
useConfirm().confirm(options) returns a promise of true or false, so a flow reads top to bottom with no dialog state in your component. One host, ConfirmHost, mounted in App.vue, renders whichever dialog the variant asks for and keeps it mounted through its close animation.
A second confirm() while one is open cancels the first, resolving it false, rather than queueing or throwing.
<script setup lang="ts">
import { Button } from '@shared/components/ui/button'
import { useConfirm } from '@shared/composables/useConfirm'
import { ref } from 'vue'
const { confirm } = useConfirm()
const result = ref('')
async function archive(): Promise<void> {
const ok = await confirm({
title: 'Archive this project?',
description: 'Archived projects stay searchable and stop appearing in the list.',
confirmLabel: 'Archive',
})
result.value = ok ? 'Archived' : 'Left where it was'
}
</script>
<template>
<div class="space-y-3">
<Button @click="archive">
Archive the project
</Button>
<p class="text-sm text-muted-foreground">
{{ result || 'Nothing decided yet' }}
</p>
</div>
</template>Through useConfirm, a destructive request is retitled: the dialog's heading becomes "Are you sure?" and your own title drops into the body, where it names the target. That is done once at the host, so every call site keeps passing title: 'Delete John Doe?' and the reader still sees those words, one line lower. A default confirm is left alone, because it is already phrased as a question.
API
Source: src/shared/components/feedback/ConfirmDialog.vue
Props
| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | required | You own it. |
title | string | required | The heading. A translation key or a literal. |
description | string | — | The body. |
confirmLabel | string | 'confirm.confirm' | Defaults to "Confirm". |
cancelLabel | string | 'confirm.cancel' | Defaults to "Cancel". |
variant | 'default' | 'destructive' | 'default' | The confirm button's color. |
Emits
| Event | Payload | Description |
|---|---|---|
update:open | value: boolean | false on Escape or an overlay press. |
confirm | — | The confirm button. |
cancel | — | The cancel button. |
Slots
None.
ConfirmOptions
What useConfirm().confirm() takes.
| Key | Type | Description |
|---|---|---|
title | string | Required. |
description | string | |
variant | 'default' | 'destructive' | 'multi-step' | destructive with requireMatch renders the destructive confirm; multi-step renders the multi-step confirm. |
requireMatch | string | The text the reader has to type. |
confirmLabel | string | |
cancelLabel | string |
Accessibility
- It is an alert dialog: focus moves into it, Tab is trapped inside it, and Escape closes it.
- The title and the description are wired to the dialog by the primitive, so both are announced on open.
- Both text runs carry
dir="auto". A confirm's words are composed at the call site, so they are routinely English inside a right-to-left document, where a trailing?or.would otherwise paint at the start of the line. The dialog itself still mirrors; only the text runs are pinned.