@j1nn0/vue-modal-dialog

A reusable Vue 3 modal dialog component with focus trap and ARIA support
6,016
Install
npm install @j1nn0/vue-modal-dialog
Latest Version:1.0.1
Vue:3
License:MIT
Last Updated:Oct 24, 2026
Links: GitHub  ·  npm
Maintainer: j1nn0

vue-modal-dialog

A reusable Vue 3 modal dialog component with focus trap and ARIA accessibility support.

πŸ“¦ Project Info

License npm version Downloads

βš™οΈ Build & Quality

Bundle Size Vite

πŸ›  Tech Stack

Vue TypeScript ESLint Oxlint Oxfmt Vitest


πŸ“‘ Table of Contents


✨ 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 mode prop ("light", "dark", or null to 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 dialog or alertdialog
    • 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

πŸ§ͺ 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:

  1. Import individually (recommended, enables tree-shaking)
  2. 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-labelledby points to the visible title when a header slot is supplied; otherwise provide aria-label
  • role="alertdialog" requires the describedBy prop, which is emitted as aria-describedby
  • For standard dialogs, a direct aria-describedby attribute remains a compatibility fallback; describedBy takes precedence
  • Close button uses the customizable closeLabel prop for its accessible label ("Close" by default)
  • Additional attributes such as aria-label, data-*, and id fall 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 mode prop

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
  • 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

vuedl

Vue dialog helper

17,871 29
vue-final-modal

The most powerful yet most light-weight modal library for Vue 3.

3,105,553 1,013
jenesius-vue-modal

The progressive and simple modal system for Vue.js v3

198,410 159
vodal

A vue modal with animations.

20,491 376
vue-vaul

A drawer component for Vue

9,592 0

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
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