Introduction
Introduction
@kolirt/vue-modal is a lightweight, headless modal package for Vue 3. It lets you open, stack, and control dialogs imperatively from any function — without registering modal components in templates or wiring open/close state by hand.
What you get
- Open from JS/TS — trigger modals from any function and await the user's response. A single call returns a typed promise with full TypeScript inference for props and result.
- Less template boilerplate — skip placing every modal in your templates and wiring open/close state by hand. Register one mount point and trigger any modal from code with a single call.
- Cascading modals — open multiple modals one after another while preserving their state and context. Layer a confirmation on top of a form without losing the form's data.
- Highly customizable — headless primitives with no imposed styles. Bring your own CSS, transitions, and animations — compose modals that fit any design system.
- Modal groups — isolate flows with named groups — the main app stack, confirm dialogs, side panels — each rendering in its own mount point with its own queue.
- Async components — open any Vue component, including async ones loaded on demand. Heavy modals stay out of your initial bundle and resolve through the same promise.
The problems it solves
1. Template clutter and scattered state
Every modal needs a place in the template, a boolean ref for v-if, and handlers for open/close. Five modals on one page means five copies of this dance.
<script setup lang="ts">
import { ref } from 'vue'
const isConfirmOpen = ref(false)
const isEditOpen = ref(false)
const isPreviewOpen = ref(false)
const editingId = ref<number | null>(null)
const previewUrl = ref<string | null>(null)
function deleteAccount() {
isConfirmOpen.value = true
}
function onConfirmYes() {
isConfirmOpen.value = false
api.deleteAccount()
}
function onConfirmNo() {
isConfirmOpen.value = false
}
function edit(id: number) {
editingId.value = id
isEditOpen.value = true
}
function onEditSaved() {
isEditOpen.value = false
editingId.value = null
}
function preview(url: string) {
previewUrl.value = url
isPreviewOpen.value = true
}
function onPreviewClose() {
isPreviewOpen.value = false
previewUrl.value = null
}
</script>
<template>
<button @click="deleteAccount">Delete</button>
<button @click="edit(42)">Edit</button>
<button @click="preview('/img.png')">Preview</button>
<ConfirmDialog
v-if="isConfirmOpen"
title="Delete account?"
@confirm="onConfirmYes"
@cancel="onConfirmNo"
/>
<EditDialog
v-if="isEditOpen && editingId"
:id="editingId"
@saved="onEditSaved"
@close="isEditOpen = false"
/>
<PreviewDialog
v-if="isPreviewOpen && previewUrl"
:url="previewUrl"
@close="onPreviewClose"
/>
</template>
<script setup lang="ts">
import { openModal } from '@kolirt/vue-modal'
import ConfirmDialog from './ConfirmDialog.vue'
import EditDialog from './EditDialog.vue'
import PreviewDialog from './PreviewDialog.vue'
async function deleteAccount() {
const ok = await openModal<boolean>(ConfirmDialog, {
props: { title: 'Delete account?' }
}).catch(() => false)
if (ok) await api.deleteAccount()
}
async function edit(id: number) {
await openModal(EditDialog, {
props: { id }
}).catch(() => {})
}
function preview(url: string) {
openModal(PreviewDialog, {
props: { url }
}).catch(() => {})
}
</script>
<template>
<button @click="deleteAccount">Delete</button>
<button @click="edit(42)">Edit</button>
<button @click="preview('/img.png')">Preview</button>
</template>
No v-if, no boolean refs, no @confirm/@cancel/@close plumbing. Each modal lives in its own file; the caller just awaits a result.
2. Manual stacking, scroll lock, focus trap
A confirm modal opens on top of an open form modal. Now you have two layers, and each of these has to keep working: only the topmost reacts to Esc, body scroll stays locked while any layer is open, focus stays in the topmost one and returns to the trigger when it closes. Forget one and the page scrolls behind the dialog or focus leaks out.
<!-- EditDialog.vue -->
<script setup lang="ts">
import { ref, watch, onUnmounted } from 'vue'
// Open/close state
const formOpen = ref(true)
const confirmOpen = ref(false)
function discard() {
formOpen.value = false
confirmOpen.value = false
}
// Refcounted scroll lock: a naive `body.overflow = formOpen`
// breaks the moment confirm closes while form is still open.
const locks = ref(0)
function lockScroll() {
if (locks.value++ === 0) {
document.body.style.overflow = 'hidden'
}
}
function unlockScroll() {
if (--locks.value === 0) {
document.body.style.overflow = ''
}
}
watch(formOpen, (v) => (v ? lockScroll() : unlockScroll()), { immediate: true })
watch(confirmOpen, (v) => (v ? lockScroll() : unlockScroll()))
onUnmounted(() => {
while (locks.value > 0) unlockScroll()
})
// Focus trap, focus restore, and Esc routed to the topmost layer:
// ~80 LoC per layer, omitted here.
</script>
<template>
<Teleport to="body">
<div v-if="formOpen" class="fixed inset-0 z-50 ...">
<form>...</form>
<button @click="confirmOpen = true">Discard</button>
</div>
<div v-if="confirmOpen" class="fixed inset-0 z-60 ...">
Discard changes?
<button @click="confirmOpen = false">Cancel</button>
<button @click="discard">OK</button>
</div>
</Teleport>
</template>
<!-- EditDialog.vue -->
<script setup lang="ts">
import { ModalRoot, ModalContent, openModal, useModalContext } from '@kolirt/vue-modal'
import ConfirmDialog from './ConfirmDialog.vue'
const { close } = useModalContext()
async function discard() {
const ok = await openModal<boolean>(ConfirmDialog, {
props: { title: 'Discard?' }
}).catch(() => false)
if (ok) close()
}
</script>
<template>
<ModalRoot>
<ModalContent>
<form>...</form>
<button @click="discard">Discard</button>
</ModalContent>
</ModalRoot>
</template>
The package stacks the confirm above the form automatically, keeps body scroll locked while either layer is open, traps focus inside the topmost, and returns focus to the trigger when it closes.
3. Close guards
Same form, now dirty: the user presses Esc or clicks the overlay. You need to ask "discard changes?" before letting the modal go — and you need to handle this for every close path, not just the explicit Close button.
<!-- EditDialog.vue -->
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
const isOpen = ref(true)
const dirty = ref(true)
const showDiscardConfirm = ref(false)
function requestClose() {
if (dirty.value) showDiscardConfirm.value = true
else isOpen.value = false
}
// You have to wire the guard into each path manually:
// Esc key — global listener, only top layer should react
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && isOpen.value && !showDiscardConfirm.value) requestClose()
}
onMounted(() => window.addEventListener('keydown', onKeydown))
onUnmounted(() => window.removeEventListener('keydown', onKeydown))
// Overlay click — bind it on the overlay div
// Manual close button — bind it on the button
// Programmatic close from a parent — they have to call requestClose, not isOpen=false
</script>
<template>
<Teleport to="body">
<div v-if="isOpen" class="fixed inset-0 z-50 ..." @click.self="requestClose">
<form>...</form>
<button @click="requestClose">Close</button>
</div>
<div v-if="showDiscardConfirm" class="fixed inset-0 z-60 ...">
Discard changes?
<button @click="showDiscardConfirm = false">Keep editing</button>
<button @click="isOpen = false; showDiscardConfirm = false">Discard</button>
</div>
</Teleport>
</template>
<!-- EditDialog.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import { ModalRoot, ModalContent, openModal, useModalContext } from '@kolirt/vue-modal'
import ConfirmDialog from './ConfirmDialog.vue'
const { close, onBeforeClose } = useModalContext()
const dirty = ref(true)
// One guard, every close path: Esc, overlay, manual button, programmatic.
onBeforeClose(async () => {
if (!dirty.value) return
const discard = await openModal<boolean>(ConfirmDialog, {
props: { title: 'Discard changes?' }
}).catch(() => false)
if (!discard) return false // veto — keep the form open
})
</script>
<template>
<ModalRoot>
<ModalContent>
<form>...</form>
<button @click="close()">Close</button>
</ModalContent>
</ModalRoot>
</template>
onBeforeClose runs before every explicit close — Esc, overlay click, and any close() / confirm() call — through the same hook. Return false to veto.
4. Split control flow
A flow that opens two modals in sequence — pick a role, then confirm — exposes how the usual @confirm / @cancel event pattern fragments. Each modal needs its own state, its own handlers, and the second modal can only start from inside the first one's @confirm. With promises the whole flow stays in one function.
<script setup lang="ts">
import { ref } from 'vue'
const isRoleOpen = ref(false)
const isConfirmOpen = ref(false)
const chosenRole = ref<'admin' | 'guest' | null>(null)
function promote() {
isRoleOpen.value = true
}
function onRoleSelected(role: 'admin' | 'guest') {
isRoleOpen.value = false
chosenRole.value = role
isConfirmOpen.value = true
}
function onRoleCancelled() {
isRoleOpen.value = false
}
async function onConfirmYes() {
isConfirmOpen.value = false
if (chosenRole.value) {
await api.promote(chosenRole.value)
}
chosenRole.value = null
}
function onConfirmNo() {
isConfirmOpen.value = false
chosenRole.value = null
}
</script>
<template>
<button @click="promote">Promote</button>
<SelectRoleDialog
v-if="isRoleOpen"
@select="onRoleSelected"
@cancel="onRoleCancelled"
/>
<ConfirmDialog
v-if="isConfirmOpen && chosenRole"
:title="`Promote to ${chosenRole}?`"
@confirm="onConfirmYes"
@cancel="onConfirmNo"
/>
</template>
<script setup lang="ts">
import { openModal } from '@kolirt/vue-modal'
import SelectRoleDialog from './SelectRoleDialog.vue'
import ConfirmDialog from './ConfirmDialog.vue'
async function promote() {
try {
const role = await openModal<'admin' | 'guest'>(SelectRoleDialog)
const ok = await openModal<boolean>(ConfirmDialog, {
props: { title: `Promote to ${role}?` }
})
if (ok) await api.promote(role)
} catch {
// user cancelled at any step — nothing to do
}
}
</script>
<template>
<button @click="promote">Promote</button>
</template>
One linear function. Two awaits. One try/catch wraps the whole sequence. No shared state stuck between handlers.
