@j1nn0/vue-modal-dialog
v1.0.0
Published
A reusable Vue 3 modal dialog component with focus trap and ARIA support
Downloads
1,754
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
- 🔒 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) - Draggable dialogs: reposition dialog by dragging the header
- Programmatic API: open a dialog with content and await its result via the
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/style.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/style.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.cjs"></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 | String | "default" | "default" = topmost backdrop click closes the dialog, "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"; alert dialogs require describedBy |
| describedBy | String | undefined | Description element id(s), emitted as aria-describedby; required with role="alertdialog" |
| closeLabel | String | "Close" | Accessible label for the close button |
| initialFocus | String | HTMLElement | undefined | Element selector or element to focus when the dialog opens |
| teleport | Boolean | String | true | Teleports to body by default; pass false to render inline or a selector for a custom target |
| scrollLock | Boolean | true | Locks page scrolling and compensates for the removed scrollbar width; changes apply immediately while 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 |
🛠 v1.0 Migration
v1.0 is modal-only. The modal prop and boolean backdrop={true|false} values are removed.
Dialogs now teleport to body by default; pass :teleport="false" only when inline rendering is required.
role="alertdialog" now requires describedBy, pointing to the alert message element.
Use backdrop="default" (the default) to close on a topmost backdrop click, or
backdrop="static" to keep the dialog open on backdrop clicks. Non-modal dialogs are not supported.
To make the backdrop visually transparent while keeping its interaction shield, override the existing CSS custom property without renaming it:
:root {
--j1nn0-vue-modal-dialog-backdrop-background: transparent;
--j1nn0-vue-modal-dialog-backdrop-background-dark: transparent;
--j1nn0-vue-modal-dialog-backdrop-blur: 0;
}🎛 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 when opening is requested. For an initially-open dialog, it fires after the initial DOM mount. |
| opening | void | Fired when the dialog starts opening. |
| opened | void | Fired after the DOM update and focus handling; it does not wait for the enter transition to complete. |
| before-close | void | Fired before closing begins and before the beforeClose guard runs; a cancelled guard may prevent any close. |
| closing | void | Fired when the dialog starts closing. |
| closed | void | Fired once Vue has applied the close; the leave transition may still be playing. |
| after-leave | void | Fired after the dialog panel's leave transition completes. |
🔓 Expose
| Method | Description |
| -------------- | ------------------------------------------------------------------------------------------------------------ |
| requestClose | Returns Promise<boolean>: true when closing starts; false when a guard rejects or is already pending. |
♿ Accessibility
- Dialogs are always modal: the topmost dialog has
aria-modal="true"and an active focus trap - Lower-layered dialogs have
aria-modal="false"+inert, so they leave the tab order as well as the accessibility tree aria-labelledbypoints to the visible title when a header slot is supplied; otherwise providearia-labelrole="alertdialog"requires thedescribedByprop, which is emitted asaria-describedby- For standard dialogs, a direct
aria-describedbyattribute remains a compatibility fallback;describedBytakes precedence - Close button uses the customizable
closeLabelprop for its accessible label ("Close"by default) - Additional attributes such as
aria-label,data-*, andidfall through to the dialog - 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)
Background page content is not marked inert. Modality is conveyed with aria-modal and enforced
with a focus trap. The backdrop is the interaction shield; make it visually transparent with the
migration CSS above when needed. Dialogs teleport to body by default, avoiding transformed ancestors
and local stacking contexts.
🎨 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;
/* Close button */
--j1nn0-vue-modal-dialog-close-size: 24px;
--j1nn0-vue-modal-dialog-close-border-radius: 4px;
--j1nn0-vue-modal-dialog-close-hover-background: rgba(0, 0, 0, 0.08);
/* Focus ring */
--j1nn0-vue-modal-dialog-focus-ring-color: #1d4ed8;
--j1nn0-vue-modal-dialog-focus-ring-width: 2px;
--j1nn0-vue-modal-dialog-focus-ring-offset: 2px;
/* 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;
--j1nn0-vue-modal-dialog-close-hover-background-dark: rgba(255, 255, 255, 0.12);
--j1nn0-vue-modal-dialog-focus-ring-color-dark: #93c5fd;
}Keeping --j1nn0-vue-modal-dialog-close-size at 24px or above satisfies WCAG 2.2 SC 2.5.8
(Target Size, Minimum). Transitions are shortened automatically when the user prefers reduced motion.
📝 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; fullscreen dialogs do not render a separate backdrop because the dialog covers the viewport
- Focus trap is active only for the topmost dialog
- ARIA attributes are updated by stack position:
- topmost dialog:
aria-modal="true", interactive - lower-layered dialogs:
aria-modal="false",inert
- 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
Use useDialog to open a dialog imperatively and await the value passed to close.
<script setup lang="ts">
import { h } from 'vue';
import { useDialog } from '@j1nn0/vue-modal-dialog';
const dialog = useDialog();
async function confirmDelete() {
const confirmed = await dialog.open<boolean>({
header: 'Delete file?',
content: () => h('p', 'This cannot be undone.'),
footer: () =>
h('button', { type: 'button', onClick: () => dialog.close(true) }, 'OK'),
});
if (confirmed) {
// Delete the file.
}
}
</script>
<template>
<button type="button" @click="confirmDelete">Delete file</button>
</template>Dialogs opened this way are mounted in their own Vue app, so they do not inherit the host app's
plugins, global components, or provide() values. Use <VueModalDialog v-model="isOpen"> in a
template when those values are required.
🖱 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>🔒 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
