vue-modal

Vue version TypeScript version NPM downloads NPM unpacked size License NPM version

๐Ÿงฉ Call modals imperatively, declare them declaratively โ€” a minimal, type-safe modal framework for Vue 3.

vue-modal lets you register a modal once and then drive it from anywhere โ€” open it, close it, hand it fully-typed props โ€” without prop-drilling a single isOpen flag through your component tree. It's the elevator, not the staircase: you don't pour a new shaft on every floor, you mount one collector and press the button (modal.open()) from wherever you happen to be standing.

๐Ÿ–ค Features

  • Type-safe by construction โ€” useModal reads your component's own props and makes you pass the required ones; the wrong shape gets caught by your editor, not by your users.

  • Open and close from anywhere โ€” modal state lives outside the component tree, so whoever holds the composable holds the off-switch. No isOpen ref drilled through five components that don't care.

  • Teleport-rendered โ€” mount one <ModalCollector /> and every modal renders at <body> with a z-index that always wins; no overflow: hidden parent clips it in half.

  • Esc and click-outside โ€” both dismissals wired out of the box, the two things every human reflexively reaches for.

  • Zero CSS to import โ€” built-in fade (tune it with transitionDuration), styles injected straight from the JS; no stylesheet for you to forget.

And the bits you'd expect to just work:

  • one modal on screen at a time (why would you ever want two? ๐Ÿคจ)

  • a ModalCollector slot for bringing your own overlay and wrapper

  • <script setup>-native, Composition API throughout

  • an unplugin resolver + preset, so useModal and ModalCollector auto-import themselves


Table of Contents

โธป

๐Ÿ“ฆ Installation

BASH
npm install @dsplce-co/vue-modal
# or
yarn add @dsplce-co/vue-modal
# or
pnpm add @dsplce-co/vue-modal

That's the whole dependency โ€” it leans on Vue 3 and nothing else you have to install yourself (see Requirements).

โธป

๐Ÿงช Usage

Four steps: install the plugin, drop the collector in once, write a modal, open it. The first two you do a single time; after that it's just useModal wherever you need it.

1. Install the plugin

The plugin wires up the global modal state every useModal call reaches into. Add it in your app entry:

JS
import { createApp } from 'vue';
import VueModalPlugin from '@dsplce-co/vue-modal';
import App from './App.vue';

const app = createApp(App);

app.use(VueModalPlugin);
app.mount('#app');

2. Drop in the collector

ModalCollector is the single place all your modals actually render โ€” your one elevator shaft. Put it once at your app root and never think about it again:

VUE
<template>
  <div id="app">
    <router-view />

    <users-view /> <!-- We'll get to this in a moment -->

    <modal-collector />
  </div>
</template>

<script setup>
import { ModalCollector } from '@dsplce-co/vue-modal';
</script>

3. Build a modal

Here's the part people expect to be hard and isn't: a modal is just a component. Say you've got a user list and you want an "are you sure?" before deleting one โ€” that confirmation dialog is a plain Vue component that takes props like any other and emits close when it's done:

VUE
<template>
  <div class="confirmation-modal">
    <h2>Confirm Action</h2>
    <p>Are you sure you want to delete {{ user.name }}?</p>

    <div class="confirmation-modal__actions">
      <button @click="$emit('close')">Cancel</button>
      <button @click="confirmDelete">Confirm</button>
    </div>
  </div>
</template>

<script setup lang="ts">
import type { User } from './UsersView.vue';

const props = defineProps({
  user: {
    type: Object as () => User,
    required: true,
  },
  onConfirm: {
    type: Function,
    required: true,
  },
});

const emit = defineEmits(['close']);

const confirmDelete = () => {
  props.onConfirm(props.user.id);
  emit('close');
};
</script>

<style>
.confirmation-modal {
  background: white;
  padding: 2rem;
  max-width: 400px;
  width: 100%;
}

.confirmation-modal__actions {
  display: flex;
  gap: .5rem;
  margin-top: 1.5rem;
  justify-content: flex-end;
}

.confirmation-modal__actions button {
  padding: 0.5rem 1rem;
  border: none;
  cursor: pointer;
}

.confirmation-modal__actions button:first-child {
  background: #e5e7eb;
}

.confirmation-modal__actions button:last-child {
  background: #ff3b89;
  color: white;
}
</style>

4. Open it with useModal

Now wire it up. useModal takes your component and hands back an open/close pair โ€” and because it read your props, it knows user and onConfirm are required and won't let you call open() without them:

VUE
<template>
  <div class="users-view">
    <!-- โ— Notice the ConfirmationModal is not mounted directly -->
    <!-- anywhere โ€” it is the ModalCollector's job to render modals -->
    <ul>
      <li v-for="user in users" :key="user.id">
        {{ user.name }}
        <button @click="onDelete(user)">Delete</button>
      </li>
    </ul>
  </div>
</template>

<script setup lang="ts">
import { useModal } from '@dsplce-co/vue-modal';
import { ref } from 'vue';
import ConfirmationModal from './ConfirmationModal.vue';

export type User = {
  id: string;
  name: string;
};

const users = ref<User[]>([
  { id: '1', name: 'Walter White' },
  { id: '2', name: 'Hank Schrader' },
]);

const deleteUser = (id) => {
  console.log('Deleting user with id:', id);
  // Your deletion logic here
};

// Register the modal
const modal = useModal(ConfirmationModal);

const onDelete = (user: User) => {
  // Open modal with required props
  modal.open({
    user,
    onConfirm: () => deleteUser(user.id),
  });
};
</script>

โธป

๐Ÿ“ API reference

VueModalPlugin

The Vue plugin that sets up global modal state. Install it once โ€” everything else assumes it's there, and useModal throws a clear error if it isn't:

JS
import { createApp } from 'vue';
import VueModalPlugin from '@dsplce-co/vue-modal';

app.use(VueModalPlugin);

ModalCollector

The component that renders the active modal, via Vue's <teleport>. Mount it once, near your app root.

PropTypeDefaultWhat it does
transitionDurationstring"0.5s"Length of the fade in/out โ€” any CSS time value ("200ms", "1s", โ€ฆ)

It also exposes a default slot โ€” { component, payload, close } โ€” for when you want to render the overlay and wrapper yourself. See Custom modal overlay and wrapper.

VUE
<template>
  <ModalCollector />
  <!-- or, slower fade: -->
  <ModalCollector transition-duration="1s" />
</template>

useModal

Creates a typed controller for one modal component:

JS
import { useModal } from '@dsplce-co/vue-modal';

const modal = useModal(YourModalComponent);

Returns:

  • open(props) โ€” opens the modal, passing props through to your component

  • close() โ€” closes whatever's open

Type safety is the whole point. The controller infers from your component whether props are required or optional, and the compiler holds you to it:

TS
// If the modal has required props
modal.open({ requiredProp: 'value' }); // โœ… TypeScript enforces this

// If the modal has only optional props
modal.open(); // โœ… props can be omitted
modal.open({ optionalProp: 'value' }); // โœ… or provided

ModalOverlay

The default backdrop โ€” a fixed, centered, blurred overlay that the collector wraps your modal in automatically. You only import it directly when you're building a custom collector slot and want the stock backdrop back. Like ModalCollector, it's exported from the package and resolvable via the unplugin resolver.

What the collector handles

  • Teleport โ€” modals render at <body> level, above everything (z-index: 10000)

  • Backdrop โ€” a semi-transparent overlay with a blur(4px) behind your content

  • Dismissal โ€” Esc and click-outside both close the active modal

  • Transitions โ€” fade in/out, timed by transitionDuration

  • One at a time โ€” opening a modal replaces whatever was already open

  • Centering โ€” full-viewport overlay, content centered

On accessibility: vue-modal deliberately stays out of your modal's content, which means it doesn't impose ARIA roles or a focus trap on you. role="dialog", aria-modal, labelling and focus management belong in your own modal component โ€” that's where the content lives, so that's where they make sense. Wire them there.

Writing a modal component

Whatever you open just needs to play along with two things:

  1. Emit close โ€” $emit('close') (or defineEmits(['close'])) is how your modal asks to be dismissed. The collector also closes on Esc and click-outside, but an in-modal "Close" button needs this.

  2. Declare props normally โ€” defineProps as usual; the collector binds whatever you passed to open() straight onto your component.

Styling the content is yours; the overlay and positioning are the collector's (see Styling).

VUE
<template>
  <div class="my-modal">
    <h2>{{ title }}</h2>
    <button @click="$emit('close')">Close</button>
  </div>
</template>

<script setup lang="ts">
defineProps({
  title: {
    type: String,
    required: true,
  },
});

defineEmits(['close']);
</script>

โธป

๐ŸŽจ Styling

The library brings the bare minimum: the overlay, the blur, the centering, the fade. Everything inside the modal โ€” the card, the padding, the buttons โ€” is yours, because it's your component. No design opinions shipped, nothing of yours to override.

โธป

๐Ÿ”ง Advanced usage

Auto-import with unplugin

vue-modal ships a resolver for unplugin-vue-components and a preset for unplugin-auto-import, so the components and the composable show up without you importing them by hand.

BASH
npm install -D unplugin-vue-components unplugin-auto-import
TS
// vite.config.ts
import Components from 'unplugin-vue-components/vite'
import AutoImport from 'unplugin-auto-import/vite'
import { VueModalResolver, VueModalPreset } from '@dsplce-co/vue-modal/resolver'

export default defineConfig({
  plugins: [
    Components({ resolvers: [VueModalResolver()] }),
    AutoImport({ imports: [VueModalPreset] }),
  ],
})

Now ModalCollector and ModalOverlay are auto-imported as components, and useModal is globally available without imports:

VUE
<script setup lang="ts">
import ConfirmationModal from './ConfirmationModal.vue'

const modal = useModal(ConfirmationModal) // no import needed
</script>

<template>
  <ModalCollector /> <!-- no import needed -->
</template>

Custom modal overlay and wrapper

Don't like the stock overlay? The ModalCollector default slot hands you the active component, its payload, and a close function โ€” render the lot however you like:

VUE
<ModalCollector v-slot="{ component, payload, close }">
  <div v-if="component !== null" class="custom-overlay">
    <div class="custom-modal-container">
      <div class="modal-header">
        <button @click="close">ร—</button>
      </div>

      <component :is="component" v-bind="payload" @close="close" />
    </div>
  </div>
</ModalCollector>

โธป

๐Ÿ› ๏ธ Requirements

  • Vue 3 โ€” declared as a peer dependency (3.x)

โธป

๐Ÿ“ Repo & Contributions

๐Ÿ“ฆ Package: @dsplce-co/vue-modal
๐Ÿ› ๏ธ Repo: github.com/dsplce-co/vue-modal

Contributions, issues, suggestions welcome. Hit us up ๐Ÿ–ค

โธป

๐Ÿ“„ License

MIT or Apache-2.0, at your option.