Vue Final Modal
Components

<VueFinalDialog>

A headless modal built on the native <dialog> element.

<VueFinalDialog> renders a native <dialog> and opens it with showModal(). It plugs into useModal(), v-model and useVfm() like <VueFinalModal>, and its open() and close() resolve with the same results.

The browser does the rest: the dialog sits in the top layer, so it needs no z-index, the page behind it is inert, Esc closes it, the focus moves into it on open and back on close. <VueFinalDialog> ships no styles and no transitions of its own.

When to use it

<VueFinalModal> stays the default of useModal() and the one to customize: it is a plain element you can place, layer and animate as you like. Use <VueFinalDialog> when you want what the browser gives a modal <dialog>: focus moved in and back, the page behind it inert and hidden from screen readers, and on Android the back gesture closing it. Simple confirmations and forms are a good fit.

The top layer and the inert page come with limits that <VueFinalModal> does not have:

  • Nothing outside the dialog can be used while it is open, whatever its z-index. A dropdown, a date picker or a toast that a UI library teleports to <body> shows behind the dialog and cannot be clicked: point its teleport target at the dialog instead. A <VueFinalModal> is moved in for you, see Next to <VueFinalModal>.
  • The page behind a modal dialog cannot stay interactive, like with background: 'interactive'. A non-modal dialog, with modal: false, leaves it interactive but is not in the top layer either.
  • A modal dialog always covers the whole viewport: it cannot be kept inside a container, like <VueFinalModal> with teleportTo: false.
  • It opens once the app has hydrated, so it is never part of the first paint, see Server-side rendering.
  • z-index and the JavaScript hooks of Vue transitions have no effect: dialogs stack in the order they opened and animate with CSS.

Template structure

VueFinalDialog.vue
<template>
  <dialog :closedby="closedby">
    <slot :close="close" />
  </dialog>
</template>

Every attr you pass, such as class or aria-labelledby, falls through to the <dialog>.

Usage

With useModal()

import { useModal, VueFinalDialog } from 'vue-final-modal'

const { open, close } = useModal({
  component: VueFinalDialog,
  attrs: {
    class: 'my-dialog',
    closedby: 'any',
    onClosed() { /* on closed */ },
  },
  slots: { default: '<p>Hello from a native dialog</p>' },
})

await open() // 'opened'

With v-model

<script setup lang="ts">
import { ref } from 'vue'
import { VueFinalDialog } from 'vue-final-modal'

const show = ref(false)
</script>

<template>
  <button @click="show = true">
    Open
  </button>
  <VueFinalDialog v-model="show" class="my-dialog">
    <template #default="{ close }">
      <p>Hello from a native dialog</p>
      <button @click="close">
        Close
      </button>
    </template>
  </VueFinalDialog>
</template>

Esc, a click outside with closedby="any" and a <form method="dialog"> all close the dialog in the browser, and <VueFinalDialog> writes false back to v-model.

Transitions

Animate the dialog and its ::backdrop with CSS. opened and closed, and so open() and close(), wait for the transitions and animations that run on the <dialog> itself and on its ::backdrop, so a dialog opened by useModal() is unmounted only after it faded out:

.my-dialog {
  opacity: 1;
  transition: opacity .3s, overlay .3s allow-discrete, display .3s allow-discrete;
}
.my-dialog:not([open]) {
  opacity: 0;
}
@starting-style {
  .my-dialog[open] {
    opacity: 0;
  }
}

The animations of the content inside the dialog, and animations that never end, are not waited for.

Props

modelValue (optional)

Display the dialog or not.

  • Type:
    • JS: Boolean
    • TS: PropType<boolean>
  • Default: undefined

modalId (optional)

An uniq value to open and close the dialog with useVfm(): open(modalId), close(modalId), toggle(modalId) and closeAll().

  • Type:
    • JS: [String, Number, Symbol]
    • TS: PropType<ModalId>
  • Default: undefined

Open the dialog with showModal(). Set it to false to open it with show() instead: a non-modal dialog leaves the page interactive and is not in the top layer. It applies the next time the dialog opens.

  • Type:
    • JS: Boolean
    • TS: PropType<boolean>
  • Default: true

closedby (optional)

The native closedby attribute: 'any' also closes the dialog on a click outside, 'none' keeps it open on Esc. Browsers that do not support it ignore it.

  • Type:
    • JS: String
    • TS: PropType<'any' | 'closerequest' | 'none'>
  • Default: undefined

lockScroll

Lock the scroll of the page while the dialog is open, with @hunterliu/scroll-lock. A modal dialog makes the page inert but does not stop it from scrolling. The page keeps the room of its scrollbar, so its content does not shift.

  • Type:
    • JS: Boolean
    • TS: PropType<boolean>
  • Default: false

Events

  • (e: 'beforeOpen', event: { stop: () => void }): void: Emits before the dialog opens.
    • event.stop() keeps it closed. The pending open() then resolves with 'stopped' instead of 'opened'.
  • (e: 'opened'): void: Emits after the dialog opened and its transitions ended.
  • (e: 'beforeClose', event: { stop: () => void }): void: Emits before the dialog closes, also on Esc and on a click outside with closedby="any".
    • event.stop() keeps it open. The pending close() then resolves with 'stopped' instead of 'closed'.
    • On Esc the browser lets the page keep the dialog open only after the user interacted with the page, and not for a second Esc in a row: then the dialog closes anyway.
    • A <form method="dialog"> closes the dialog without asking, so beforeClose is not emitted for it.
  • (e: 'closed'): void: Emits after the dialog closed and its transitions ended. A dialog opened by useModal() is unmounted right after, unless it has keepAlive.
  • (e: 'cancel', event: Event): void: The native cancel event of a close request, such as Esc. event.preventDefault() keeps the dialog open without asking beforeClose, within the same limits as above.
  • (e: 'close', event: Event): void: The native close event, fired once the <dialog> closed and before its transitions end.

Read the returnValue of a <form method="dialog"> in close:

useModal({
  component: VueFinalDialog,
  attrs: {
    onClose: (event: Event) => console.log((event.target as HTMLDialogElement).returnValue),
  },
})

Slots

  • The default slot renders the content of the dialog. Its close closes the dialog.

Server-side rendering

The dialog is rendered closed on the server, with its content, and opens once the app has hydrated: an open <dialog> in the server HTML could not become a modal one. open() resolves on the server right away, like for <VueFinalModal>.

Next to <VueFinalModal>

A modal dialog makes everything outside it inert, so a <VueFinalModal> opened while a modal <VueFinalDialog> is open is rendered inside the topmost dialog instead of its teleportTo target. It can be used there, and Esc closes it alone: the dialog closes on the next Esc. With escToClose false, Esc closes neither. A <VueFinalModal> with teleportTo: false stays where it is rendered.

  • Esc reaches the modal that has the focus, which its focus trap moves in once it has opened.
  • The browser keeps the dialog open on Esc only after the user interacted with the page, as for beforeClose. A modal opened by a click, such as a confirm, always is.
  • While the dialog runs a transform transition, the modal inside it is laid out within the dialog until the transition ends.

Another <VueFinalDialog> opened on top of a dialog needs none of this: the browser stacks dialogs in the order they opened, only the topmost one can be used, and Esc closes one at a time.

Your own dialog component

<VueFinalDialog> is built on useModalLifecycle(), which you can use to build your own modal component that useModal() can open and await.

Copyright © 2026