Modal-X — modals that don't make you angry.
I built Modal-X because I got tired of writing the same modal boilerplate in every project. It's published on npm. Real people use it. Here's what it does and why I think the approach is better.
The problem with the standard approach
The typical Vue modal pattern looks like this — a boolean ref per modal,
a v-if in the template, event emitters for results,
and manual state cleanup on close:
<!-- The standard approach. Every page. Every project. -->
<template>
<div v-if="showUserForm" class="modal-backdrop">
<UserForm
:user="selectedUser"
@save="handleSave"
@close="showUserForm = false"
/>
</div>
<div v-if="showConfirm" class="modal-backdrop">
<Confirmation
@confirm="handleConfirm"
@cancel="showConfirm = false"
/>
</div>
</template>
<script setup>
const showUserForm = ref(false);
const showConfirm = ref(false);
const selectedUser = ref(null);
const handleSave = (result) => { /* ... */ };
const handleConfirm = () => { /* ... */ };
</script> This works for one modal. Add five, and your component has five booleans, five handlers, and a template full of conditionals. The modal logic is scattered across the file — and if you want the result of one modal to trigger another, you're wiring event handlers to event handlers. It gets ugly fast.
The Modal-X approach: files and promises
Modal-X treats modals as files, not component state.
You put them in a src/modals/ folder using a naming convention,
and the plugin discovers them automatically. Opening one returns a Promise —
so you can await the result like any async operation:
<!-- With Modal-X -->
<script setup>
import { openModal } from '@customizer/modal-x';
async function editUser(user) {
const result = await openModal('UserForm', { user });
if (result?.saved) refresh();
}
</script>
No boolean. No event handler. No cleanup. The modal opens, the user
interacts, it resolves. Same mental model as fetch().
Setup
Install the plugin once in main.js — it mounts a single
root container to the document body and initializes the global modal stack:
// main.js
import modal from '@customizer/modal-x';
const app = createApp(App);
app.use(modal); // mounts a root container once, globally
app.mount('#app'); Add the Vite plugin for automatic type generation and the auto-inference feature (more on that below):
// vite.config.js
import { modalTypesPlugin } from '@customizer/modal-x/modalxPlugin.cjs';
export default defineConfig({
plugins: [
vue(),
modalTypesPlugin({ autoInference: true }),
],
}); File conventions
The naming convention tells the system how each file should behave:
src/modals/
UserForm.mdl.vue // eager-loaded modal
Confirmation.mdl.vue // eager-loaded modal
AddDriver.amdl.vue // lazy-loaded (code-split)
AddDriver.s.vue // spinner shown while AddDriver loads
GlobalSpinner.g.vue // fallback spinner for any lazy modal .mdl.vue— eager-loaded. Available immediately, no spinner needed.amdl.vue— lazy-loaded. Code-split, loaded on first open.s.vue— spinner shown while the async modal chunk loads.g.vue— global fallback spinner if no specific spinner is defined
Defining a modal
A modal component exports its Props and ReturnType
as TypeScript types. The plugin reads those exports and auto-generates
the defineProps block — you don't write it manually:
<!-- src/modals/UserForm.mdl.vue -->
<script setup lang="ts">
export type Props = { userId: string; name: string };
export type ReturnType = { saved: boolean; newName: string };
// Plugin auto-generates this line from your exported types:
// [MODAL-X] AUTO-GENERATED INSTANCE
defineProps<{ data: Props; close: (res: ReturnType) => void }>();
</script>
<template>
<form @submit.prevent="close({ saved: true, newName: form.name })">
<input v-model="form.name" />
<button type="submit">Save</button>
<button type="button" @click="close({ saved: false, newName: data.name })">
Cancel
</button>
</form>
</template>
Inside the modal, data holds the props you passed in,
and close(result) resolves the promise with whatever
you hand it. Clean in, clean out.
Type safety end-to-end
Because the plugin generates a registry from your exported types, TypeScript knows exactly what each modal expects and what it returns — at the call site, with full autocomplete:
import { openModal, MODALS } from '@customizer/modal-x';
// TypeScript knows exactly what data UserForm expects
// and what it returns — no guessing, no any
const result = await openModal(MODALS.UserForm, {
userId: '123', // required, string — TS validates this
name: 'Abel', // required, string
});
// result is typed as: { saved: boolean; newName: string }
if (result?.saved) {
console.log(result.newName); // autocomplete works here
}
The MODALS constant gives you "go to definition" navigation
directly to the modal file. This is the part I'm actually proud of —
most modal libraries treat modals as stringly-typed strings.
Modal-X doesn't.
A real pattern: confirmation dialogs
Confirmation modals are the most repetitive thing in any CRUD app. With Modal-X they become one-liners:
// A real pattern I use constantly
async function deleteVehicle(id: string) {
const confirmed = await openModal('Confirmation', {
title: 'Delete vehicle?',
message: 'This cannot be undone.',
});
if (confirmed) await api.delete('/vehicles/' + id);
} Modal stacking
Multiple open modals stack automatically. Each openModal call
pushes onto a global stack — the topmost modal is active, the ones below
are inert. Closing drops back to the previous one.
Z-index and active state are managed for you:
// Modals stack — each openModal call pushes on top
await openModal('VehicleList');
// user clicks a vehicle row, which calls:
await openModal('VehicleDetail', { vehicleId: id });
// user clicks edit, which calls:
await openModal('VehicleForm', { vehicleId: id });
// Closing VehicleForm drops back to VehicleDetail
// Closing VehicleDetail drops back to VehicleList
// Z-index and active state are managed automatically Focus trapping and body scroll locking are also built in. The kind of a11y details that are easy to skip and annoying to add later.
Auto-inference: the Vite plugin magic
With autoInference: true, the Vite plugin watches your modal
files for changes to Props and ReturnType,
and automatically updates the defineProps block.
You define the types, the plugin keeps the component in sync:
// You write this:
export type Props = { title: string };
export type ReturnType = boolean;
// The Vite plugin watches your file and injects:
// [MODAL-X] AUTO-GENERATED INSTANCE
defineProps<{ data: Props; close: (res: ReturnType) => void }>();
// It updates the injected block automatically whenever
// you change Props or ReturnType. No manual sync needed. Why I built it
Honestly? I was maintaining a codebase with 30+ modals, all using the boolean ref pattern, and I kept introducing bugs when I forgot to reset state on close. I wanted something where the happy path was also the correct path — where you couldn't forget to clean up because there was nothing to clean up. The promise resolves, the modal disappears, done.
The file-based convention came from wanting modals to be findable.
In a large codebase, a single src/modals/ folder with
.mdl.vue files is easier to navigate than hunting for
modal components scattered across feature folders. Grep for .mdl.vue,
you find every modal in the project. Simple.