@j1nn0/vue-modal-dialog
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 storybook
Build static Storybook output:
pnpm build-storybook
πΎ Installation
npm install @j1nn0/vue-modal-dialog
or
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-trap
or
yarn add vue @vueuse/core @vueuse/integrations focus-trap
These 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
Related Packages
Version History
| Version | Released | Vue | License |
|---|---|---|---|
| 1.0.1 | ^3.5.38 | MIT | |
| 1.0.0 | ^3.5.38 | MIT | |
| 0.13.5 | ^3.5.38 | MIT | |
| 0.13.4 | ^3.5.38 | MIT | |
| 0.13.3 | ^3.5.38 | MIT | |
| 0.13.1 | ^3.5.27 | MIT | |
| 0.13.0 | ^3.5.27 | MIT | |
| 0.11.1 | ^3.5.27 | MIT | |
| 0.11.0 | ^3.5.27 | MIT | |
| 0.10.5 | ^3.5.27 | MIT | |
| 0.10.4 | ^3.5.27 | MIT | |
| 0.10.3 | ^3.5.27 | MIT | |
| 0.9.6 | ^3.5.27 | MIT | |
| 0.9.2 | ^3.5.27 | MIT | |
| 0.9.1 | ^3.5.25 | MIT | |
| 0.9.0 | ^3.5.23 | MIT | |
| 0.8.2 | ^3.5.18 | MIT | |
| 0.8.1 | ^3.5.18 | MIT | |
| 0.8.0 | ^3.5.18 | MIT | |
| 0.7.0 | ^3.5.18 | MIT | |
| 0.6.11 | ^3.5.18 | MIT | |
| 0.6.10 | ^3.5.18 | MIT | |
| 0.6.9 | ^3.5.18 | MIT | |
| 0.6.8 | ^3.5.18 | MIT | |
| 0.6.4 | ^3.5.18 | MIT | |
| 0.6.3 | ^3.5.18 | MIT | |
| 0.6.2 | ^3.5.18 | MIT | |
| 0.6.1 | ^3.5.18 | MIT | |
| 0.6.0 | ^3.5.18 | MIT | |
| 0.5.12 | ^3.5.18 | MIT |
Showing the latest 30 of 64. See every release on npm
Pre-releases (11)
| Version | Released | Vue | License |
|---|---|---|---|
| 1.0.0-rc.11 | ^3.5.38 | MIT | |
| 1.0.0-rc.10 | ^3.5.38 | MIT | |
| 1.0.0-rc.9 | ^3.5.38 | MIT | |
| 1.0.0-rc.8 | ^3.5.38 | MIT | |
| 1.0.0-rc.7 | ^3.5.38 | MIT | |
| 1.0.0-rc.6 | ^3.5.38 | MIT | |
| 1.0.0-rc.5 | ^3.5.38 | MIT | |
| 1.0.0-rc.4 | ^3.5.38 | MIT | |
| 1.0.0-rc.3 | ^3.5.38 | MIT | |
| 1.0.0-rc.2 | ^3.5.38 | MIT | |
| 1.0.0-rc.1 | ^3.5.38 | MIT |