Recipes

Confirm dialog

A reusable ConfirmDialog component paired with a confirm() helper that returns Promise<boolean>. Covers loading state and destructive variants.

Confirm dialog

The most common modal pattern: ask the user to confirm an action, get a boolean back. This recipe builds a reusable <ConfirmDialog> component and a thin confirm() utility that wraps openModal.

Group registration

main.ts
import { createModal } from '@kolirt/vue-modal'
import type { DefineGroups } from '@kolirt/vue-modal'

declare module '@kolirt/vue-modal' {
  interface ModalGroupRegistry extends DefineGroups<['confirm']> {}
}

app.use(createModal())
App.vue
<script setup lang="ts">
import { ModalOverlay, ModalTarget } from '@kolirt/vue-modal'
</script>

<template>
  <RouterView />
  <ModalTarget group="confirm">
    <ModalOverlay class="bg-black/40" />
  </ModalTarget>
</template>

<ModalOverlay> is a sibling of the modal stack inside <ModalTarget> — never inside <ModalContent>. It paints behind every modal in the group automatically.

The ConfirmDialog component

<script setup lang="ts">
import { ref } from 'vue'
import {
  ModalRoot,
  ModalContent,
  ModalTitle,
  ModalDescription,
  useModalContext
} from '@kolirt/vue-modal'

defineOptions({ modalGroup: 'confirm' })

const props = withDefaults(
  defineProps<{
    title?: string
    description?: string
    confirmLabel?: string
    cancelLabel?: string
    destructive?: boolean
    /** Async action run before confirming. Returning false aborts. */
    onConfirm?: () => Promise<boolean | void>
  }>(),
  {
    title: 'Are you sure?',
    description: undefined,
    confirmLabel: 'Confirm',
    cancelLabel: 'Cancel',
    destructive: false,
    onConfirm: undefined
  }
)

const { close, confirm } = useModalContext<boolean>()
const loading = ref(false)

async function handleConfirm() {
  if (props.onConfirm) {
    loading.value = true
    try {
      const result = await props.onConfirm()
      if (result === false) return
    } finally {
      loading.value = false
    }
  }
  confirm(true)
}
</script>

<template>
  <ModalRoot class="flex items-center justify-center p-4">
    <ModalContent class="w-full max-w-sm rounded-xl bg-white shadow-xl p-6 space-y-4">
        <div>
          <ModalTitle class="text-lg font-semibold text-gray-900">
            {{ props.title }}
          </ModalTitle>
          <ModalDescription
            v-if="props.description"
            class="mt-1 text-sm text-gray-500"
          >
            {{ props.description }}
          </ModalDescription>
        </div>

        <div class="flex justify-end gap-3 pt-2">
          <button
            class="rounded-lg px-4 py-2 text-sm font-medium text-gray-700 ring-1 ring-gray-300 hover:bg-gray-50 disabled:opacity-50"
            :disabled="loading"
            @click="close()"
          >
            {{ props.cancelLabel }}
          </button>

          <button
            class="rounded-lg px-4 py-2 text-sm font-medium text-white disabled:opacity-50 flex items-center gap-2"
            :class="props.destructive ? 'bg-red-600 hover:bg-red-700' : 'bg-indigo-600 hover:bg-indigo-700'"
            :disabled="loading"
            @click="handleConfirm"
          >
            <svg
              v-if="loading"
              class="h-4 w-4 animate-spin"
              viewBox="0 0 24 24"
              fill="none"
            >
              <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
              <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
            </svg>
            {{ props.confirmLabel }}
          </button>
        </div>
    </ModalContent>
  </ModalRoot>
</template>

Basic usage

DeleteButton.vue (script)
import { confirm } from '@/utils/confirm'
import { api } from '@/api'

async function deleteItem(id: number) {
  if (await confirm('Delete this item?', { destructive: true })) {
    await api.items.delete(id)
  }
}

Loading state — async confirm

Pass onConfirm to run an async operation inside the modal before it closes. The button shows a spinner; returning false keeps the modal open.

if (
  await confirm('Submit report?', {
    description: 'This will notify all stakeholders.',
    confirmLabel: 'Submit',
    onConfirm: async () => {
      await api.reports.submit(reportId)
      // return false here to abort (e.g. on validation failure)
    }
  })
) {
  toast.success('Report submitted')
}
onConfirm runs inside the modal. Errors thrown there propagate to the caller; the spinner stops automatically via finally.

Overlay

The backdrop is rendered by <ModalOverlay> placed inside <ModalTarget> (see the App.vue snippet above). It applies to every modal in that group — you don't include it in each individual modal component.

Copyright © 2026