@j1nn0/vue-modal-dialog
v0.13.1
Published
A reusable Vue 3 modal dialog component with focus trap and ARIA support
Maintainers
Readme
vue-modal-dialog
A reusable Vue 3 modal dialog component with focus trap and ARIA accessibility support.
📦 Project Info
⚙️ Build & Quality
🛠 Tech Stack
📑 Table of Contents
- ✨ Features
- 🧪 Storybook
- 💾 Installation
- ⚙️ Peer Dependencies
- 🛠 Usage
- 🌐 CDN Usage
- 📌 Props
- 🎛 Slots
- 🔔 Events
- 🔓 Expose
- 🎯 Programmatic API
- 🖱 Draggable Dialogs
- 🚪 Non-modal Dialogs
- 🔒 Prevent Close
- ♿ Accessibility
- 🎨 Styles
- 📝 Notes on Multiple Modals
- 🏷 License
✨ Features
- Vue 3 support
- Focus trap inside the modal
- Focus restoration: the element that opened the dialog is re-focused when the last dialog closes
- Keyboard accessibility (Escape to close)
- Backdrop with blur and fade animation
- Supports multiple modals opened simultaneously with automatic stack management
- Header, body, and footer slots
- Optional footer slot
- Close button in the header
- Configurable dialog size:
sm,md,lg,fullscreen - Configurable dialog width (supports custom widths via width prop for flexible layouts)
- Supports dark mode and light mode via the
modeprop ("light","dark", ornullto follow OS/browser preference) - New v0.12.0 Features:
- Teleport support: render dialog anywhere in the DOM (e.g., to
body) - Non-modal support: allow background interaction
- Draggable dialogs: reposition dialog by dragging the header
- Programmatic API: control dialog state via
useDialog()composable - Before-close guard: prevent closing based on logic (e.g., unsaved changes)
- Custom transitions: configurable entry/exit animations for dialog and backdrop
- Initial focus: explicitly define which element to focus on open
- Role configuration: choose between
dialogoralertdialog - Body scroll locking: automatically prevent background scrolling
- Expanded positioning: 9-way positioning system (center, top, bottom, corners, etc.)
- New lifecycle events:
before-open,opening,before-close,closing - Programmatic close:
requestClose()method exposed to parents
- Teleport support: render dialog anywhere in the DOM (e.g., to
🧪 Storybook
Use Storybook to interactively verify modal behavior and props.
pnpm storybookBuild static Storybook output:
pnpm build-storybook💾 Installation
npm install @j1nn0/vue-modal-dialogor
yarn add @j1nn0/vue-modal-dialog⚙️ Peer Dependencies
Before using this component, make sure you have installed the following peer dependencies:
npm install vue @vueuse/core @vueuse/integrations focus-trapor
yarn add vue @vueuse/core @vueuse/integrations focus-trapThese dependencies are required for the library to function properly.
🛠 Usage
You can use this component in two ways:
- Import individually (recommended, enables tree-shaking)
- Register globally as a Vue plugin
1️⃣ Individual Import (recommended)
<script setup>
import { ref } from 'vue';
import { VueModalDialog } from '@j1nn0/vue-modal-dialog';
import '@j1nn0/vue-modal-dialog/dist/vue-modal-dialog.css';
const isOpen = ref(false);
const submitForm = () => {
alert('Form submitted!');
isOpen.value = false;
};
</script>
<template>
<button @click="isOpen = true">Open Dialog</button>
<VueModalDialog v-model="isOpen">
<!-- Header slot -->
<template #header> Dialog Title </template>
<!-- Body slot (default) -->
<p>
This is the body content of the dialog. It supports long text and will wrap automatically.
</p>
<!-- Footer slot (optional) -->
<template #footer>
<button @click="isOpen = false">Cancel</button>
<button @click="submitForm">Submit</button>
</template>
</VueModalDialog>
</template>Multiple Modals (Stack)
You can open multiple dialogs at the same time. Stack behavior is handled automatically.
<script setup>
import { ref } from 'vue';
import { VueModalDialog } from '@j1nn0/vue-modal-dialog';
const showDialog1 = ref(false);
const showDialog2 = ref(false);
</script>
<template>
<button @click="showDialog1 = true">Open Dialog 1</button>
<VueModalDialog v-model="showDialog1">
<template #header>Dialog 1</template>
<p>First dialog</p>
<template #footer>
<button @click="showDialog2 = true">Open Dialog 2</button>
</template>
</VueModalDialog>
<VueModalDialog v-model="showDialog2">
<template #header>Dialog 2</template>
<p>Second dialog (topmost while open)</p>
</VueModalDialog>
</template>When multiple dialogs are open, only the topmost dialog handles Escape and backdrop close.
2️⃣ Global Plugin Registration
// main.js
import { createApp } from 'vue';
import App from './App.vue';
import { VueModalDialogPlugin } from '@j1nn0/vue-modal-dialog';
import '@j1nn0/vue-modal-dialog/dist/vue-modal-dialog.css';
const app = createApp(App);
// Registers globally as <VueModalDialog> by default
app.use(VueModalDialogPlugin);
// Or specify a custom name
// app.use(VueModalDialogPlugin, { name: 'CustomName' });
app.mount('#app');Use <VueModalDialog> (or your custom name) anywhere in your app without importing it:
<template>
<VueModalDialog v-model="isOpen">
<template #header> Global Dialog </template>
<p>Body content</p>
</VueModalDialog>
</template>🌐 CDN Usage
You can use @j1nn0/vue-modal-dialog via CDN without any bundler. Both individual import and global plugin usage are supported.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vue Modal Dialog CDN Example</title>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script src="https://unpkg.com/tabbable/dist/index.umd.js"></script>
<script src="https://unpkg.com/focus-trap/dist/focus-trap.umd.js"></script>
<script src="https://unpkg.com/@vueuse/shared"></script>
<script src="https://unpkg.com/@vueuse/core"></script>
<script src="https://unpkg.com/@vueuse/integrations"></script>
<link
rel="stylesheet"
href="https://unpkg.com/@j1nn0/vue-modal-dialog/dist/vue-modal-dialog.css"
/>
<script src="https://unpkg.com/@j1nn0/vue-modal-dialog/dist/vue-modal-dialog.umd.js"></script>
</head>
<body>
<div id="app">
<!-- Individual Import -->
<button type="button" @click="isOpenImport = true">Open Import Dialog</button>
<vue-modal-dialog v-model="isOpenImport">
<template #header>Import Dialog Title</template>
<p>Body content goes here</p>
<template #footer>
<button @click="isOpenImport = false">Close</button>
</template>
</vue-modal-dialog>
<!-- Global Plugin -->
<button type="button" @click="isOpenGlobal = true">Open Global Dialog</button>
<global-plugin-modal-dialog v-model="isOpenGlobal">
<template #header>Global Dialog Title</template>
<p>Body content goes here</p>
<template #footer>
<button @click="isOpenGlobal = false">Close</button>
</template>
</global-plugin-modal-dialog>
</div>
<script>
const { createApp, ref } = Vue;
const { VueModalDialogPlugin, VueModalDialog } = J1nn0VueModalDialog;
const app = createApp({
setup() {
const isOpenImport = ref(false);
const isOpenGlobal = ref(false);
return { isOpenImport, isOpenGlobal };
},
});
// Individual import registration
app.component('VueModalDialog', VueModalDialog);
// Global plugin registration (default name: 'VueModalDialog')
app.use(VueModalDialogPlugin, { name: 'GlobalPluginModalDialog' });
app.mount('#app');
</script>
</body>
</html>📌 Props
| Prop | Type | Default | Description |
| -------------------- | ------------------------- | ----------------- | -------------------------------------------------------------------------------------------------------------------------- |
| backdrop | Boolean | String | true | true = backdrop click closes dialog, false = no backdrop, "static" = backdrop shown but click does not close |
| escape | Boolean | true | Pressing Escape key closes the dialog |
| role | String | "dialog" | ARIA role: "dialog" or "alertdialog" |
| initialFocus | String | HTMLElement | undefined | Element selector or element to focus when the dialog opens |
| modal | Boolean | true | true = blocks background interaction and traps focus |
| teleport | Boolean | String | false | true = teleports to body, or specify a CSS selector target |
| scrollLock | Boolean | true | Locks page scrolling while the dialog is open |
| draggable | Boolean | false | Enables dragging the dialog by its header |
| transition | String | "fade" | Transition name for the dialog panel |
| backdropTransition | String | "fade-backdrop" | Transition name for the backdrop layer |
| beforeClose | Function | undefined | Async or sync callback; return false to prevent closing |
| position | String | "center" | Position: "center", "top", "bottom", "left", "right", "topleft", "topright", "bottomleft", "bottomright" |
| width | String | "md" | Dialog width. Presets: sm, md, lg, fullscreen. Also supports custom CSS width, e.g. "400px", "50%", "80vw" |
| mode | String | null | null | Dialog color mode: "light" for light mode, "dark" for dark mode, null to follow the OS/browser preference |
🎛 Slots
| Slot | Description |
| -------- | --------------------------------------------------------- |
| header | Optional. Content for the header. × button always present |
| default | Content for the body of the dialog |
| footer | Optional. Content for footer, not rendered if empty |
🔔 Events
| Event | Payload | Description |
| -------------- | ------- | --------------------------------------------------- |
| before-open | void | Fired before the dialog begins its opening sequence |
| opening | void | Fired when the opening transition starts |
| opened | void | Fired when the opening transition completes |
| before-close | void | Fired before the dialog begins its closing sequence |
| closing | void | Fired when the closing transition starts |
| closed | void | Fired when the closing transition completes |
🔓 Expose
| Method | Description |
| -------------- | --------------------------------------------------------------------------- |
| requestClose | Programmatically request to close the dialog. Respects beforeClose guard. |
♿ Accessibility
role="dialog"+aria-modal="true"on the topmost dialogaria-modal="false"+aria-hidden="true"on lower-layered dialogsaria-labelledbypoints to header slotaria-describedbypoints to body slot- Close button has
aria-label="Close" - Focus trap is active on the topmost dialog to keep keyboard navigation predictable
- Focus is restored to the element that was focused before the first dialog opened when the last dialog closes
- Escape key closes the dialog if enabled (topmost dialog only when stacked)
🎨 Styles
- Dialog width:
sm,md,lg,fullscreen - Dialog height: auto, max
80vh(default), scrollable if content overflows - Word wrapping enabled in header and body
- Backdrop has fade-in/out animation with blur effect
- Supports Light and Dark mode via
modeprop
CSS Custom Properties
:root {
/* Backdrop */
--j1nn0-vue-modal-dialog-backdrop-z-index: 1000;
--j1nn0-vue-modal-dialog-backdrop-background: rgba(0, 0, 0, 0.6);
--j1nn0-vue-modal-dialog-backdrop-blur: 2px;
/* Dialog */
--j1nn0-vue-modal-dialog-border: none;
--j1nn0-vue-modal-dialog-border-radius: 8px;
--j1nn0-vue-modal-dialog-width: 90%;
--j1nn0-vue-modal-dialog-max-width-sm: 300px;
--j1nn0-vue-modal-dialog-max-width-md: 600px;
--j1nn0-vue-modal-dialog-max-width-lg: 900px;
--j1nn0-vue-modal-dialog-max-height: 80vh;
--j1nn0-vue-modal-dialog-text-color: #000000;
/* Header */
--j1nn0-vue-modal-dialog-header-background: #f5f5f5;
--j1nn0-vue-modal-dialog-header-padding: 1rem;
/* Body */
--j1nn0-vue-modal-dialog-body-background: #fff;
--j1nn0-vue-modal-dialog-body-padding: 1rem;
/* Footer */
--j1nn0-vue-modal-dialog-footer-background: #f5f5f5;
--j1nn0-vue-modal-dialog-footer-padding: 1rem;
/* Dark mode */
--j1nn0-vue-modal-dialog-backdrop-background-dark: rgba(255, 255, 255, 0.2);
--j1nn0-vue-modal-dialog-border-dark: none;
--j1nn0-vue-modal-dialog-header-background-dark: #1f2937;
--j1nn0-vue-modal-dialog-footer-background-dark: #1f2937;
--j1nn0-vue-modal-dialog-body-background-dark: #111827;
--j1nn0-vue-modal-dialog-text-color-dark: #f9fafb;
}📝 Notes on Multiple Modals
This library supports multiple modals opened simultaneously.
When dialogs are stacked:
- Only the topmost dialog responds to Escape and backdrop click
- Only the topmost dialog renders a backdrop (
fullscreendialogs do not render a backdrop) - Focus trap is active only for the topmost dialog
- ARIA attributes are updated by stack position:
- topmost dialog:
aria-modal="true",aria-hidden="false" - lower-layered dialogs:
aria-modal="false",aria-hidden="true"
- topmost dialog:
- Dialog z-index is automatically calculated from stack order
- Focus is restored to the element that triggered the first dialog when all dialogs are closed
No additional configuration is required to use stack behavior.
🎯 Programmatic API
You can use the useDialog composable to control the dialog state from any child component or logic.
<script setup>
import { VueModalDialog, useDialog } from '@j1nn0/vue-modal-dialog';
const { isOpen, open, close } = useDialog();
</script>
<template>
<button @click="open">Open via API</button>
<VueModalDialog v-model="isOpen">
<p>Controlled via useDialog()</p>
<button @click="close">Close</button>
</VueModalDialog>
</template>🖱 Draggable Dialogs
Enable header-based dragging by adding the draggable prop.
<VueModalDialog v-model="isOpen" draggable>
<template #header>Drag Me</template>
<p>You can move this dialog anywhere on the screen.</p>
</VueModalDialog>🚪 Non-modal Dialogs
Set modal to false to allow interaction with the background while the dialog is open.
<VueModalDialog v-model="isOpen" :modal="false" backdrop="static">
<p>The background remains interactive.</p>
</VueModalDialog>🔒 Prevent Close
Use beforeClose to add validation or confirmation before the dialog closes.
<script setup>
const handleBeforeClose = async () => {
return window.confirm('You have unsaved changes. Close anyway?');
};
</script>
<template>
<VueModalDialog v-model="isOpen" :beforeClose="handleBeforeClose">
<p>Try to close me.</p>
</VueModalDialog>
</template>🏷 License
MIT License
Copyright © 2025–PRESENT j1nn0
