Elegant and type-safe modal/dialog/etc system for Vue 3 with composable API
- π― Full TypeScript support - automatic type inference from component props
- π Promise-based API - work with modals as async/await
- π Composable approach - use modals as Vue 3 composables
- π¨ Flexible design - full control over modal appearance
- π¦ Lightweight - minimal dependencies
- π Easy integration - single container component for all modals
- β»οΈ Smart lifecycle - automatic cleanup on unmount
npm install universal-container-vue<script setup lang="ts">
import { UniversalContainer } from 'universal-container-vue'
</script>
<template>
<div id="app">
<router-view />
<!-- Add modal container -->
<UniversalContainer />
</div>
</template><!-- ConfirmModal.vue -->
<script setup lang="ts">
defineProps<{
show: boolean
input: {
title: string
message: string
}
}>()
const emit = defineEmits<{
close: [result: boolean]
cancel: [reason: Error]
}>()
function onConfirm() {
emit('close', true)
}
function onCancel() {
emit('cancel', new Error('User cancelled'))
}
</script>
<template>
<div v-show="show" class="modal">
<div class="overlay" @click="onCancel" />
<div class="content">
<h2>{{ input.title }}</h2>
<p>{{ input.message }}</p>
<div class="actions">
<button @click="onCancel">
Cancel
</button>
<button @click="onConfirm">
Confirm
</button>
</div>
</div>
</div>
</template>
<style scoped>
.modal {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.5);
}
.content {
position: relative;
background: white;
padding: 24px;
border-radius: 12px;
max-width: 400px;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
}
.actions {
display: flex;
gap: 12px;
margin-top: 24px;
}
button {
flex: 1;
padding: 10px 20px;
border: none;
border-radius: 8px;
cursor: pointer;
font-weight: 500;
}
</style>// useConfirmModal.ts
import { createEntityFactory } from 'universal-container-vue'
import { defineAsyncComponent } from 'vue'
const ConfirmModal = defineAsyncComponent(() => import('./ConfirmModal.vue'))
export const useConfirmModal = createEntityFactory(ConfirmModal)<script setup lang="ts">
import { useConfirmModal } from './useConfirmModal'
// Initialize the modal composable
const confirmModal = useConfirmModal()
async function handleDelete() {
try {
const confirmed = await confirmModal.open({
title: 'Delete item?',
message: 'This action cannot be undone',
})
if (confirmed) {
// User confirmed the action
console.log('Deleting item...')
}
}
catch (error) {
// User cancelled the action
console.log('Action cancelled')
}
}
</script>
<template>
<button @click="handleDelete">
Delete
</button>
</template><!-- SimpleModal.vue -->
<script setup lang="ts">
defineProps<{
show: boolean
}>()
const emit = defineEmits<{
close: []
}>()
function close() {
emit('close')
}
</script>
<template>
<div v-show="show" class="modal">
<div class="overlay" @click="close" />
<div class="content">
<h2>Hello!</h2>
<button @click="close">
Close
</button>
</div>
</div>
</template>// useSimpleModal.ts
import { createEntityFactory } from 'universal-container-vue'
import { defineAsyncComponent } from 'vue'
const SimpleModal = defineAsyncComponent(() => import('./SimpleModal.vue'))
export const useSimpleModal = createEntityFactory(SimpleModal)<!-- Usage in component -->
<script setup lang="ts">
import { useSimpleModal } from './useSimpleModal'
const simpleModal = useSimpleModal()
async function showModal() {
await simpleModal.open() // TypeScript knows props are not needed!
}
</script>
<template>
<button @click="showModal">
Show Simple Modal
</button>
</template><!-- ChoiceModal.vue -->
<script setup lang="ts">
defineProps<{
show: boolean
input: {
question: string
}
}>()
const emit = defineEmits<{
close: [result?: 'yes' | 'no' | 'maybe']
cancel: [reason: Error]
}>()
function onSelect(value: 'yes' | 'no' | 'maybe') {
emit('close', value)
}
function onCancel() {
emit('cancel', new Error('cancelled'))
}
</script>
<template>
<div v-show="show" class="modal">
<div class="overlay" @click="onCancel" />
<div class="content">
<h2>{{ input.question }}</h2>
<div class="choices">
<button @click="onSelect('yes')">
Yes
</button>
<button @click="onSelect('no')">
No
</button>
<button @click="onSelect('maybe')">
Maybe
</button>
</div>
</div>
</div>
</template>// useChoiceModal.ts
import { createEntityFactory } from 'universal-container-vue'
import { defineAsyncComponent } from 'vue'
const ChoiceModal = defineAsyncComponent(() => import('./ChoiceModal.vue'))
export const useChoiceModal = createEntityFactory(ChoiceModal)<!-- Usage in component -->
<script setup lang="ts">
import { useChoiceModal } from './useChoiceModal'
const choiceModal = useChoiceModal()
async function askUser() {
try {
const answer = await choiceModal.open({
question: 'Do you like Vue 3?',
})
console.log('User answered:', answer) // answer: 'yes' | 'no' | 'maybe' | undefined
}
catch (error) {
console.log('User cancelled')
}
}
</script>
<template>
<button @click="askUser">
Ask Question
</button>
</template><!-- InputModal.vue -->
<script setup lang="ts">
import { ref } from 'vue'
defineProps<{
show: boolean
input: {
title: string
placeholder: string
}
}>()
const emit = defineEmits<{
close: [result: string]
cancel: [reason: Error]
}>()
const inputValue = ref('')
function onSubmit() {
if (inputValue.value) {
emit('close', inputValue.value)
}
}
function onCancel() {
emit('cancel', new Error('cancelled'))
}
</script>
<template>
<div v-show="show" class="modal">
<div class="overlay" @click="onCancel" />
<div class="content">
<h2>{{ input.title }}</h2>
<input
v-model="inputValue"
:placeholder="input.placeholder"
@keyup.enter="onSubmit"
>
<div class="actions">
<button @click="onCancel">
Cancel
</button>
<button :disabled="!inputValue" @click="onSubmit">
Save
</button>
</div>
</div>
</div>
</template>// useInputModal.ts
import { createEntityFactory } from 'universal-container-vue'
import { defineAsyncComponent } from 'vue'
const InputModal = defineAsyncComponent(() => import('./InputModal.vue'))
export const useInputModal = createEntityFactory(InputModal)<!-- Usage in component -->
<script setup lang="ts">
import { useInputModal } from './useInputModal'
const inputModal = useInputModal()
async function promptUserName() {
try {
const name = await inputModal.open({
title: 'What is your name?',
placeholder: 'Enter your name',
})
console.log('Hello,', name)
}
catch (error) {
console.log('User cancelled input')
}
}
</script>
<template>
<button @click="promptUserName">
Ask Name
</button>
</template><script setup lang="ts">
import { useConfirmModal } from './useConfirmModal'
import { useInputModal } from './useInputModal'
const confirmModal = useConfirmModal()
const inputModal = useInputModal()
async function openNestedModals() {
try {
// Open first modal
const name = await inputModal.open({
title: 'Create user',
placeholder: 'Enter name',
})
// Open second modal for confirmation
const confirmed = await confirmModal.open({
title: 'Confirm creation',
message: `Create user ${name}?`,
})
if (confirmed) {
console.log('User created:', name)
}
}
catch (error) {
console.log('Operation cancelled at some stage')
}
}
</script>
<template>
<button @click="openNestedModals">
Create User
</button>
</template><!-- CircularModal.vue -->
<script setup lang="ts">
import { useCircularModal } from './useCircularModal'
const props = defineProps<{
show: boolean
input: {
level: number
}
}>()
const emit = defineEmits<{
close: []
cancel: [reason: Error]
}>()
const circularModal = useCircularModal()
async function openAnotherModal() {
await circularModal.open({
level: props.input.level + 1,
})
// Manually destroy this modal instance after the nested one closes
circularModal.destroy()
}
</script>
<template>
<div v-show="show" class="modal">
<div class="overlay" @click="emit('cancel', new Error('cancelled'))" />
<div class="content">
<h2>Level {{ input.level }}</h2>
<button @click="openAnotherModal">
Open Level {{ input.level + 1 }}
</button>
<button @click="emit('close')">
Close
</button>
</div>
</div>
</template>// useCircularModal.ts
import { createEntityFactory } from 'universal-container-vue'
import { defineAsyncComponent } from 'vue'
const CircularModal = defineAsyncComponent(() => import('./CircularModal.vue'))
export const useCircularModal = createEntityFactory(CircularModal)Creates a composable factory for modal management with automatic type inference.
Parameters:
component- Vue component to use as modal/dialog
Returns: A composable function that returns EntityController<Input, Output>
Example:
import { createEntityFactory } from 'universal-container-vue'
import { defineAsyncComponent } from 'vue'
const MyModal = defineAsyncComponent(() => import('./MyModal.vue'))
export const useMyModal = createEntityFactory(MyModal)Controller instance for managing a modal.
Methods:
open(props)- Opens the modal and returns a Promise that resolves with the resultclose(result)- Manually closes the modal with a resultcancel(reason)- Manually cancels the modal with a reasondestroy()- Destroys the modal instance completely (removes from DOM)
Example:
const modal = useMyModal()
// Open modal
const result = await modal.open({ title: 'Hello' })
// Manual close (usually not needed, use emit in component)
modal.close(result)
// Manual destroy (removes from DOM)
modal.destroy()Your modal component must follow this structure:
Required Props:
show: boolean- Controls modal visibility (use withv-show)input?: YourInputType- Input data for the modal (optional if no input needed)
Required Emits:
close: [result?: YourResultType]- Emitted when modal successfully closescancel: [reason: Error]- Emitted when modal is cancelled
Example:
<script setup lang="ts">
defineProps<{
show: boolean
input: {
title: string
}
}>()
const emit = defineEmits<{
close: [result: string]
cancel: [reason: Error]
}>()
</script>
<template>
<div v-show="show" class="modal">
<!-- Modal content -->
</div>
</template>Important: Understanding the modal lifecycle is crucial for proper memory management.
- On
open(): Modal component is created or shown (if already exists) - On
close/cancel: Modal is hidden (show = false) but NOT destroyed - On parent unmount: Modal is automatically destroyed when the parent component that called the composable unmounts
- Manual destroy: You can manually call
destroy()to remove the modal from DOM
Component Mount β useModal() β open() β [Modal Created]
β
[Modal Visible]
β
close()/cancel() emitted
β
[Modal Hidden]
(still in DOM)
β
ββββββββββββββββββββββββββββββββ
β β
Component Unmount manual destroy()
β β
[Modal Destroyed] [Modal Destroyed]
(removed from DOM) (removed from DOM)
- Performance: Reusing modal instances is faster than recreating them
- State preservation: Modal state is preserved between opens (can be useful)
- Animations: Allows for smooth exit animations
<script setup lang="ts">
import { useConfirmModal } from './useConfirmModal'
const confirmModal = useConfirmModal()
async function showModal() {
await confirmModal.open({ title: 'Confirm?' })
// Manually destroy the modal after it closes
// This is useful if you want immediate cleanup
confirmModal.destroy()
}
</script><script setup lang="ts">
import { useConfirmModal } from './useConfirmModal'
const confirmModal = useConfirmModal()
// Modal will be automatically destroyed when this component unmounts
// No manual cleanup needed in most cases
</script>The library doesn't impose any styles. You have full control over modal appearance through component CSS.
Important: Use v-show="show" instead of v-if to properly handle visibility:
<template>
<div v-show="show" class="modal">
<!-- Overlay for background dimming -->
<div class="overlay" @click="onClose" />
<!-- Modal content -->
<div class="content">
<!-- Your content -->
</div>
</div>
</template>-
Use
defineAsyncComponentfor lazy loading modals:const MyModal = defineAsyncComponent(() => import('./MyModal.vue'))
-
Always handle errors with try/catch:
try { const result = await modal.open() } catch (error) { // User cancelled or error occurred }
-
Use
v-showinstead ofv-ifin your modal component:<div v-show="show" class="modal">
-
Type your props and emits for full TypeScript support:
defineProps<{ show: boolean, input: { title: string } }>() const emit = defineEmits<{ close: [result: string], cancel: [reason: Error] }>()
-
Use emit('cancel') for user cancellation, emit('close') for success:
// Cancel emit('cancel', new Error('User cancelled')) // Success emit('close', result)
-
Manual cleanup when needed:
await modal.open() modal.destroy() // Remove from DOM immediately
-
Initialize composables in component setup, not globally:
// β Good - in component const modal = useMyModal() // β Bad - outside component const modal = useMyModal() // Won't cleanup automatically
Contributions, issues and feature requests are welcome!
ISC Β© Max Frolov
Made with β€οΈ and Vue 3