Getting Started

Introduction

What @kolirt/vue-modal is and the problems it solves.

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>

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>

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>

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>

One linear function. Two awaits. One try/catch wraps the whole sequence. No shared state stuck between handlers.

Copyright © 2026