The simplest way to deal with modal dialogs in Vue 3

Turn modal dialogs into async functions with vuejs-confirm-dialog: reuse them, chain them and await the user's answer.

10 min read

Updated in October 2026 — the original version of this article was published on dev.to in May 2022

Modal dialogs are not a difficult thing to build. They are often used to confirm a user's action, for example deleting data or logging in. It is monotonous work with repetitive logic that gets copied from component to component with minor changes.

But what if you have dozens or even hundreds of similar dialogs on one page or across the whole project? Or you need to call dialogs in a chain depending on the user's choice? How do you write a reusable function that also keeps the code readable and maintainable?

It would be useful to have a function that takes a dialog component and controls its rendering. The function returns an object with the dialog state and methods, so you can work with it like a promise. The vue-modal-dialogs plugin implemented this idea, but it has not been updated for a long time and does not support Vue 3.

In this guide, I'll introduce the vuejs-confirm-dialog plugin and show how to use it. I'll start with simple examples and end with a function that completely abstracts a confirmation dialog and can be used in any component of your project. The examples are written in JavaScript for easy reading, but the plugin itself is written in TypeScript, fully typed and documented. Besides the promise API, it has two hooks: onConfirm and onCancel. They accept a callback and run depending on the user's decision.

The full documentation lives at vcd-docs.netlify.app, and a runnable demo is in the repository.

Installation

The plugin requires Vue 3.5 or newer. Create a new project with the official scaffolding tool and install the plugin:

npm create vue@latest dialogs-guide
cd dialogs-guide
npm i vuejs-confirm-dialog

Then install the plugin in main.js:

import { createApp } from 'vue'
import App from './App.vue'
import * as ConfirmDialog from 'vuejs-confirm-dialog'

createApp(App).use(ConfirmDialog).mount('#app')

This step is recommended rather than required: with the plugin installed, dialog components can inject() whatever your app provides (router, i18n, stores). Without it, dialogs still work, just without that context.

Usage

createConfirmDialog takes a component that will act as the modal dialog as its first argument, and an object with the component's props as the second. It returns an object for controlling the dialog: reveal renders it, and onConfirm takes a callback that runs if the user agrees.

You don't need to add anything to the template: the library mounts its own render root the first time you call reveal(). For a quick test, reuse the HelloWorld component from the new project and pass its msg prop:

<!-- App.vue -->
<script setup>
import HelloWorld from './components/HelloWorld.vue'
import { createConfirmDialog } from 'vuejs-confirm-dialog'

const { reveal } = createConfirmDialog(HelloWorld, { msg: 'Hi!' })
</script>

<template>
  <button
    type="button"
    @click="reveal()"
  >
    Show dialog
  </button>
</template>

No additional logic is required. The component renders after reveal() and disappears after the user responds.

Real life example

Now let's write something closer to real use. Create SimpleDialog.vue in the components folder:

<script setup>
defineProps({ question: String })
const emit = defineEmits(['confirm', 'cancel'])
</script>

<template>
  <div class="modal-container">
    <div
      class="modal-body"
      role="dialog"
      aria-modal="true"
      aria-labelledby="modal-question"
    >
      <button
        type="button"
        class="modal-close"
        aria-label="Close"
        @click="emit('cancel')"
      >
        ✕
      </button>
      <h2 id="modal-question">{{ question }}</h2>
      <div class="modal-action">
        <button
          type="button"
          class="modal-button"
          @click="emit('confirm')"
        >
          Confirm
        </button>
        <button
          type="button"
          class="modal-button"
          @click="emit('cancel')"
        >
          Cancel
        </button>
      </div>
    </div>
  </div>
</template>

<style scoped>
.modal-container {
  position: fixed;
  inset: 0;
  display: flex;
  justify-content: center;
  align-items: center;
  background-color: rgb(0 0 0 / 0.4);
}
.modal-body {
  position: relative;
  display: flex;
  flex-direction: column;
  min-width: 250px;
  padding: 20px 40px;
  border-radius: 10px;
  background-color: #fff;
  text-align: center;
}
.modal-action {
  display: flex;
  gap: 24px;
  justify-content: center;
}
.modal-button {
  min-height: 40px;
  padding: 0 24px;
  border: 0;
  border-radius: 6px;
  background-color: #2f6fb0;
  color: #fff;
  cursor: pointer;
}
.modal-close {
  position: absolute;
  top: 8px;
  right: 8px;
  border: 0;
  background: none;
  cursor: pointer;
}
</style>

The close icon is a real <button>, and role="dialog" with aria-modal makes screen readers announce it as a dialog. The overlay is position: fixed, so it covers the viewport even on a scrolled page.

The only contract with the plugin: the dialog component must emit confirm and cancel. There is no need for a show prop or v-if inside the dialog: the plugin mounts and unmounts it for you.

Now use it to confirm an action, for example hiding the logo. The code that runs after the user agrees goes into the onConfirm callback:

<script setup>
import { ref } from 'vue'
import SimpleDialog from './components/SimpleDialog.vue'
import { createConfirmDialog } from 'vuejs-confirm-dialog'

const showLogo = ref(true)

const { reveal, onConfirm } = createConfirmDialog(SimpleDialog, {
  question: 'Are you sure you want to hide the logo?'
})

onConfirm(() => {
  showLogo.value = false
})
</script>

<template>
  <img
    v-show="showLogo"
    alt="Vue logo"
    src="./assets/logo.svg"
    width="125"
    height="125"
  >
  <button
    type="button"
    @click="reveal()"
  >
    Hide logo
  </button>
</template>

Awaiting the answer

Hooks are not the only option. reveal() returns a promise that resolves to { data, isCanceled } once the user decides, so you can write the whole flow with async/await:

const dialog = createConfirmDialog(SimpleDialog)

async function deleteNote(note) {
  const { isCanceled } = await dialog.reveal({ question: `Delete "${note.title}"?` })
  if (isCanceled) return

  await api.deleteNote(note.id)
}

Note how the question is passed to reveal() instead of createConfirmDialog(): props given to reveal override the initial ones, so a single dialog instance can ask different questions. Whatever the dialog passes to emit('confirm', payload) arrives as data. This is also how you build chains of dialogs: await one, then reveal the next depending on the answer.

Reusing

What if many places in the project need a confirmation? There is no need to call createConfirmDialog in every component. Create one instance and wrap it in a small function that answers with a boolean:

// src/composables/confirmAction.js
import SimpleDialog from '../components/SimpleDialog.vue'
import { createConfirmDialog } from 'vuejs-confirm-dialog'

const dialog = createConfirmDialog(SimpleDialog)

export async function confirmAction(question) {
  const { isCanceled } = await dialog.reveal({ question })
  return !isCanceled
}

Now use it to confirm opening external links:

<!-- App.vue -->
<script setup>
import { confirmAction } from './composables/confirmAction'

const LINKS = [
  'https://vuejs.org/',
  'https://github.com/',
  'https://vueuse.org/'
]

async function goToLink(link) {
  if (await confirmAction(`Do you want to go to ${link}?`)) {
    window.location.href = link
  }
}
</script>

<template>
  <ul>
    <li
      v-for="link in LINKS"
      :key="link"
    >
      <button
        type="button"
        @click="goToLink(link)"
      >
        {{ link }}
      </button>
    </li>
  </ul>
</template>

One instance plus await keeps the calling code linear: no callbacks, and the decision is just an if.

One thing to keep in mind when reusing an instance: props passed to reveal() are remembered. If a later call omits a prop, the dialog shows the previous value. Pass { chore: true } as the third argument of createConfirmDialog to reset props after every close (and keepInitial: true to reset them to the initial values instead of the component defaults).

Transitions

Enter and leave animations work out of the box. The plugin renders every dialog inside Vue's <TransitionGroup> without a name, so Vue's default transition classes (v-enter-active, v-leave-to and the rest) are applied to the root element of your dialog component. All you need is matching CSS, no wrappers and no extra setup.

Here is a dialog where the backdrop fades and the box scales in and out:

<!-- TransitionDialog.vue -->
<script setup>
defineProps({
  msg: {
    type: String,
    default: 'This modal fades and scales in/out via <TransitionGroup>'
  }
})

const emit = defineEmits(['confirm', 'cancel'])
</script>

<template>
  <div
    class="transition-modal"
    @click.self="emit('cancel')"
  >
    <div class="modal-box">
      <p>{{ msg }}</p>
      <div class="modal-action">
        <button
          type="button"
          class="btn btn-primary"
          @click="emit('confirm', true)"
        >
          Confirm
        </button>
        <button
          type="button"
          class="btn"
          @click="emit('cancel', false)"
        >
          Cancel
        </button>
      </div>
    </div>
  </div>
</template>

<style>
.transition-modal {
  position: fixed;
  inset: 0;
  z-index: 100;
  display: flex;
  align-items: center;
  justify-content: center;
  background-color: #cecece89;
}

/* Vue's default transition classes, applied to the dialog's root element */
.transition-modal.v-enter-active,
.transition-modal.v-leave-active {
  transition: opacity 0.3s ease;
}
.transition-modal.v-enter-active .modal-box,
.transition-modal.v-leave-active .modal-box {
  transition: transform 0.3s ease;
}
.transition-modal.v-enter-from,
.transition-modal.v-leave-to {
  opacity: 0;
}
.transition-modal.v-enter-from .modal-box,
.transition-modal.v-leave-to .modal-box {
  transform: scale(0.85);
}
</style>

A few details worth noting:

  • The transition classes are set on the dialog's root element, so the selectors start with the root class (.transition-modal.v-enter-active). Nested elements are animated through descendant selectors, like the scaling .modal-box here.
  • @click.self on the backdrop closes the dialog on a click outside the box, but not on clicks inside it.
  • Vue waits for the leave transition to finish before removing the element, so you don't need any timers.

Use it like any other dialog: createConfirmDialog(TransitionDialog).reveal().

A backdrop is not required. The classes go to whatever the root element is, so a dialog without an overlay, such as a toast in the corner, is animated with rules on the root alone:

<!-- ToastDialog.vue -->
<script setup>
defineProps({ msg: String })
const emit = defineEmits(['confirm', 'cancel'])
</script>

<template>
  <div
    class="toast"
    role="status"
  >
    <p>{{ msg }}</p>
    <button
      type="button"
      @click="emit('confirm')"
    >
      OK
    </button>
  </div>
</template>

<style>
.toast {
  position: fixed;
  right: 16px;
  bottom: 16px;
}
.toast.v-enter-active,
.toast.v-leave-active {
  transition: opacity 0.3s ease, transform 0.3s ease;
}
.toast.v-enter-from,
.toast.v-leave-to {
  opacity: 0;
  transform: translateY(12px);
}
</style>

The one requirement comes from <TransitionGroup> itself: the dialog component must render a single root element. Transitions won't run if the template has several top-level elements (a fragment), if the root has a v-if (while it is false, the root is just a comment node), or if the root is a <Teleport>. Scoped styles are fine: the root element gets the scope attribute, so .toast.v-enter-active still matches.

If your animation is not CSS-based (for example, a JavaScript animation library), the closeDelay option keeps the component mounted for the given number of milliseconds after it closes: createConfirmDialog(TransitionDialog, {}, { closeDelay: 300 }).

Closing programmatically

Sometimes a dialog has to close without the user's action, for example an alert that disappears after a few seconds. The instance has a close() method for that; it does not trigger the onConfirm or onCancel hooks. To close every open dialog at once, call closeAll().

const alert = createConfirmDialog(Alert)

alert.reveal()
setTimeout(() => alert.close(), 3000)

Nuxt

The plugin works with SSR as a regular Vue plugin, no separate Nuxt module is needed:

// plugins/confirm-dialog.ts
import * as ConfirmDialog from 'vuejs-confirm-dialog'

export default defineNuxtPlugin((nuxtApp) => {
  nuxtApp.vueApp.use(ConfirmDialog)
})

Dialogs only ever mount in the browser: the render root is created lazily on the first reveal(), and a reveal() that happens during server rendering does nothing at all, so no dialog state can leak between requests on a long-lived server.

Conclusion

createConfirmDialog makes modal windows easier to work with: you reuse the logic, await the user's answer and build chains of dialogs, while the plugin takes care of rendering, passing props in and getting data back out.

For the best developer experience, use it with TypeScript: the props you pass to createConfirmDialog and reveal are checked against your dialog component.

Feedback, issues and pull requests are welcome on GitHub.

Legacy: Vue 2 and 0.x

Everything above is written for the current 1.x line, which supports Vue 3.5+ only. If your project is still on Vue 2, use the 0.x line: 0.6.0 is the last release with Vue 2 support (through vue-demi), and it lives in the legacy branch. Pin the version so you don't pick up 1.x by accident, and install the Composition API plugin:

npm i vuejs-confirm-dialog@^0.6.0 @vue/composition-api

The main difference in usage: in 0.x dialogs render through the <DialogsWrapper/> component, so it has to be placed in App.vue:

<!-- App.vue -->
<template>
  <div class="app">
    <!-- your app -->
  </div>
  <DialogsWrapper />
</template>

0.6.0 also received the fixes collected over the years: a TypeScript error when the dialog component has default prop values, a dialog that sometimes did not close when reveal() was immediately followed by confirm() or cancel(), and the closeDelay option for exit animations.

Upgrading from 0.x to 1.x takes three steps: move to Vue 3.5 or newer, remove <DialogsWrapper/> from the template, and keep app.use(ConfirmDialog) as before.

Comments

Loading comments…

Leave a comment

Comments appear after a quick review.