Appearance
@vibe-labs/design-vue-modals
Vue 3 modal and drawer components for the Vibe Design System. Focus trapping, scroll locking (with nested modal support), teleport, and CSS-driven enter/exit animations.
Installation
ts
import { VibeModal, VibeModalHeader, VibeModalBody, VibeModalFooter, useModal } from "@vibe-labs/design-vue-modals";Requires the CSS layer from @vibe-labs/design-components-modals.
Components
VibeModal
Root modal container with backdrop, animation lifecycle, focus trap, and scroll lock.
Usage
vue
<!-- Basic -->
<VibeModal v-model:open="isOpen">
<VibeModalHeader>Edit Profile</VibeModalHeader>
<VibeModalBody>Form content here</VibeModalBody>
<VibeModalFooter>
<VibeButton variant="ghost" @click="isOpen = false">Cancel</VibeButton>
<VibeButton @click="save">Save</VibeButton>
</VibeModalFooter>
</VibeModal>
<!-- Sizes -->
<VibeModal v-model:open="isOpen" size="sm">Small modal</VibeModal>
<VibeModal v-model:open="isOpen" size="lg">Large modal</VibeModal>
<VibeModal v-model:open="isOpen" size="xl">Extra large</VibeModal>
<VibeModal v-model:open="isOpen" size="full">Full screen</VibeModal>
<!-- Danger confirmation -->
<VibeModal v-model:open="confirmOpen" variant="danger" centered size="sm">
<VibeModalHeader>
Delete Item
<template #description>This action cannot be undone.</template>
</VibeModalHeader>
<VibeModalBody>Are you sure you want to delete this item?</VibeModalBody>
<VibeModalFooter>
<VibeButton variant="ghost" @click="confirmOpen = false">Cancel</VibeButton>
<VibeButton variant="danger" @click="handleDelete">Delete</VibeButton>
</VibeModalFooter>
</VibeModal>
<!-- Drawer (right-side panel) -->
<VibeModal v-model:open="drawerOpen" drawer size="md">
<VibeModalHeader>Details</VibeModalHeader>
<VibeModalBody>Drawer content</VibeModalBody>
</VibeModal>
<!-- Drawer from left (e.g. navigation) -->
<VibeModal v-model:open="navOpen" drawer drawer-side="left" size="sm">
<VibeModalBody>Navigation menu</VibeModalBody>
</VibeModal>
<!-- Drawer without backdrop dimming -->
<VibeModal v-model:open="panelOpen" drawer no-backdrop size="md">
<VibeModalBody>Side panel content</VibeModalBody>
</VibeModal>
<!-- Close guard (unsaved changes) -->
<VibeModal v-model:open="isOpen" :before-close="confirmDiscard">
<VibeModalHeader>Edit Profile</VibeModalHeader>
<VibeModalBody>Form content here</VibeModalBody>
</VibeModal>
<!-- Seamless (no section borders) -->
<VibeModal v-model:open="isOpen" seamless>
<VibeModalBody>Clean look</VibeModalBody>
</VibeModal>
<!-- No close button, no backdrop close -->
<VibeModal v-model:open="isOpen" no-close :close-on-backdrop="false">
<VibeModalHeader>Required Action</VibeModalHeader>
<VibeModalBody>Complete this before continuing.</VibeModalBody>
</VibeModal>
<!-- Custom initial focus -->
<VibeModal v-model:open="isOpen" initial-focus="#name-input">
<VibeModalBody>
<VibeInput id="name-input" v-model="name" label="Name" />
</VibeModalBody>
</VibeModal>
<!-- Scoped slot for close -->
<VibeModal v-model:open="isOpen" v-slot="{ close }">
<VibeModalBody>
<VibeButton @click="close">Done</VibeButton>
</VibeModalBody>
</VibeModal>
<!-- Disable teleport (for SSR or special layouts) -->
<VibeModal v-model:open="isOpen" :teleport="false">
...
</VibeModal>Props
| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | — | Visibility (v-model:open) |
size | ModalSize | "md" | sm · md · lg · xl · full |
variant | ModalVariant | — | Style variant (e.g. "danger") |
seamless | boolean | — | Remove section borders |
centered | boolean | — | Centre-align body content |
drawer | boolean | — | Edge-docked drawer mode |
drawerSide | ModalDrawerSide | "right" | Which edge the drawer slides from (left · right). Only applies when drawer is true |
noBackdrop | boolean | — | Hide backdrop overlay (drawer still traps focus) |
beforeClose | () => boolean | Promise<boolean> | — | Guard called before close. Return false to prevent |
closeOnBackdrop | boolean | true | Close on backdrop click |
closeOnEscape | boolean | true | Close on Escape key |
trapFocus | boolean | true | Trap focus within modal |
lockScroll | boolean | true | Lock body scroll |
teleport | string | false | "body" | Teleport target |
ariaLabel | string | — | Accessible label (overrides titleId) |
initialFocus | string | "none" | — | CSS selector for initial focus |
noClose | boolean | false | Hide close button in header |
Events
| Event | Payload | Description |
|---|---|---|
update:open | boolean | Open state changed |
opened | — | Enter animation complete |
closed | — | Exit animation complete |
Scoped Slot
| Property | Type | Description |
|---|---|---|
close | () => void | Close the modal |
VibeModalHeader
Header section with title, optional description, actions slot, and auto-wired close button.
vue
<VibeModalHeader>
Modal Title
<template #description>Optional subtitle text</template>
<template #actions="{ close }">
<VibeButton variant="ghost" icon @click="close"><MinimiseIcon /></VibeButton>
</template>
</VibeModalHeader>
<!-- Screen-reader only -->
<VibeModalHeader sr-only>Accessible title</VibeModalHeader>The close button is auto-rendered from the parent VibeModal's context (hidden when noClose is set). Title and description IDs are auto-wired for aria-labelledby/aria-describedby.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
srOnly | boolean | false | Visually hide the header |
Slots
| Slot | Description |
|---|---|
default | Title content |
description | Subtitle / description |
actions | Custom header actions (receives { close }) |
close-icon | Custom close button icon |
VibeModalBody
Scrollable content area.
vue
<VibeModalBody>
<p>Main modal content.</p>
</VibeModalBody>VibeModalFooter
Footer section for actions. Supports split layout.
vue
<!-- Standard (right-aligned) -->
<VibeModalFooter>
<VibeButton variant="ghost" @click="close">Cancel</VibeButton>
<VibeButton @click="save">Save</VibeButton>
</VibeModalFooter>
<!-- Split (space-between) -->
<VibeModalFooter split>
<VibeButton variant="danger">Delete</VibeButton>
<div>
<VibeButton variant="ghost">Cancel</VibeButton>
<VibeButton>Confirm</VibeButton>
</div>
</VibeModalFooter>Props
| Prop | Type | Default | Description |
|---|---|---|---|
split | boolean | false | Push first/last children to opposite ends |
Composables
useModal(initialOpen?)
Simple reactive state helper for controlling a modal.
ts
import { useModal } from "@vibe-labs/design-vue-modals";
const confirmModal = useModal();
function onDelete() {
confirmModal.open();
}vue
<VibeModal v-model:open="confirmModal.isOpen.value">
...
</VibeModal>Returns { isOpen, open, close, toggle }.
useFocusTrap(options)
Traps Tab/Shift+Tab within a container. Stores and restores the previously focused element. Uses requestAnimationFrame for teleport compatibility.
ts
import { useFocusTrap } from "@vibe-labs/design-vue-modals";
useFocusTrap({
containerRef: el,
enabled: isActive,
initialFocus: "#first-input",
});useScrollLock(enabled)
Locks body scroll with reference counting for nested modals. Compensates for scrollbar removal to prevent layout shift.
ts
import { useScrollLock } from "@vibe-labs/design-vue-modals";
useScrollLock(computed(() => isOpen.value));Animation Lifecycle
The modal uses a four-state machine: hidden → entering → open → exiting → hidden. The DOM stays mounted during exiting so CSS animations can complete. A 500ms fallback timeout handles cases where animations are skipped (e.g. prefers-reduced-motion).
Nested Modals
Scroll lock uses global reference counting — opening a second modal while one is already open doesn't double-lock, and closing the inner modal doesn't prematurely unlock scroll. Escape key uses stopPropagation so only the topmost modal closes.
Dependencies
| Package | Purpose |
|---|---|
@vibe-labs/design-components-modals | CSS tokens + generated styles |
Build
bash
npm run buildBuilt with Vite + vite-plugin-dts. Outputs ES module with TypeScript declarations.
Usage Guide
Setup
ts
import { VibeModal, VibeModalHeader, VibeModalBody, VibeModalFooter, useModal } from "@vibe-labs/design-vue-modals";
import "@vibe-labs/design-components-modals";VibeModal
Edit modal with form
vue
<script setup lang="ts">
import { VibeModal, VibeModalHeader, VibeModalBody, VibeModalFooter } from "@vibe-labs/design-vue-modals";
import { VibeButton } from "@vibe-labs/design-vue-buttons";
import { VibeInput } from "@vibe-labs/design-vue-inputs";
import { ref } from "vue";
const isOpen = ref(false);
const name = ref("");
async function save() {
await api.updateProfile({ name: name.value });
isOpen.value = false;
}
</script>
<template>
<VibeButton @click="isOpen = true">Edit Profile</VibeButton>
<VibeModal v-model:open="isOpen" size="md" initial-focus="#name-input">
<VibeModalHeader>
Edit Profile
<template #description>Update your public information.</template>
</VibeModalHeader>
<VibeModalBody>
<VibeInput id="name-input" v-model="name" label="Display Name" required />
</VibeModalBody>
<VibeModalFooter>
<VibeButton variant="ghost" @click="isOpen = false">Cancel</VibeButton>
<VibeButton @click="save">Save Changes</VibeButton>
</VibeModalFooter>
</VibeModal>
</template>Confirmation dialog with useModal
vue
<script setup lang="ts">
import { VibeModal, VibeModalHeader, VibeModalBody, VibeModalFooter, useModal } from "@vibe-labs/design-vue-modals";
import { VibeButton } from "@vibe-labs/design-vue-buttons";
const deleteModal = useModal();
const emit = defineEmits<{ deleted: [] }>();
async function handleDelete() {
await api.deleteItem(props.id);
deleteModal.close();
emit("deleted");
}
</script>
<template>
<VibeButton variant="danger" @click="deleteModal.open()">Delete</VibeButton>
<VibeModal v-model:open="deleteModal.isOpen.value" variant="danger" centered size="sm">
<VibeModalHeader>
Delete Item
<template #description>This action cannot be undone.</template>
</VibeModalHeader>
<VibeModalBody>
Are you sure you want to permanently delete this item?
</VibeModalBody>
<VibeModalFooter>
<VibeButton variant="ghost" @click="deleteModal.close()">Cancel</VibeButton>
<VibeButton variant="danger" @click="handleDelete">Delete</VibeButton>
</VibeModalFooter>
</VibeModal>
</template>Drawer (right-side panel)
vue
<script setup lang="ts">
import { VibeModal, VibeModalHeader, VibeModalBody } from "@vibe-labs/design-vue-modals";
import { ref } from "vue";
const drawerOpen = ref(false);
defineProps<{ trackId: string }>();
</script>
<template>
<VibeButton @click="drawerOpen = true">View Details</VibeButton>
<VibeModal v-model:open="drawerOpen" drawer size="lg">
<VibeModalHeader>Track Details</VibeModalHeader>
<VibeModalBody>
<TrackDetailView :id="trackId" />
</VibeModalBody>
</VibeModal>
</template>Drawer from left (navigation)
vue
<script setup lang="ts">
import { VibeModal, VibeModalBody } from "@vibe-labs/design-vue-modals";
import { VibeMenu, VibeMenuItem, VibeMenuGroup } from "@vibe-labs/design-vue-menus";
import { VibeButton } from "@vibe-labs/design-vue-buttons";
import { ref } from "vue";
const navOpen = ref(false);
</script>
<template>
<VibeButton variant="ghost" icon aria-label="Open menu" @click="navOpen = true">
<MenuIcon :size="20" />
</VibeButton>
<VibeModal v-model:open="navOpen" drawer drawer-side="left" size="sm"
aria-label="Navigation">
<VibeModalBody>
<VibeMenu nav aria-label="Main navigation">
<VibeMenuGroup label="Pages">
<VibeMenuItem :to="{ name: 'dashboard' }" label="Dashboard" @click="navOpen = false" />
<VibeMenuItem :to="{ name: 'settings' }" label="Settings" @click="navOpen = false" />
</VibeMenuGroup>
</VibeMenu>
</VibeModalBody>
</VibeModal>
</template>Drawer without backdrop
vue
<template>
<!-- Panel overlays content without dimming the page behind it -->
<VibeModal v-model:open="panelOpen" drawer no-backdrop size="md">
<VibeModalHeader>Filters</VibeModalHeader>
<VibeModalBody>
<FilterPanel />
</VibeModalBody>
</VibeModal>
</template>Close guard (unsaved changes)
vue
<script setup lang="ts">
import { VibeModal, VibeModalHeader, VibeModalBody, VibeModalFooter } from "@vibe-labs/design-vue-modals";
import { VibeButton } from "@vibe-labs/design-vue-buttons";
import { ref } from "vue";
const isOpen = ref(false);
const isDirty = ref(false);
async function confirmDiscard(): Promise<boolean> {
if (!isDirty.value) return true;
return window.confirm("You have unsaved changes. Discard them?");
}
</script>
<template>
<VibeModal v-model:open="isOpen" :before-close="confirmDiscard">
<VibeModalHeader>Edit Profile</VibeModalHeader>
<VibeModalBody>
<VibeInput v-model="name" label="Name" @update:model-value="isDirty = true" />
</VibeModalBody>
<VibeModalFooter>
<VibeButton variant="ghost" @click="isOpen = false">Cancel</VibeButton>
<VibeButton @click="save">Save</VibeButton>
</VibeModalFooter>
</VibeModal>
</template>Common Patterns
Modal with scoped close slot
vue
<template>
<VibeModal v-model:open="isOpen" no-close v-slot="{ close }">
<VibeModalBody>
<p>Complete this step to continue.</p>
<VibeButton @click="completeAndClose(close)">Continue</VibeButton>
</VibeModalBody>
</VibeModal>
</template>
<script setup lang="ts">
function completeAndClose(close: () => void) {
doSomething();
close();
}
</script>Full-screen modal for mobile
vue
<template>
<VibeModal v-model:open="isOpen" size="full">
<VibeModalHeader>Browse Tracks</VibeModalHeader>
<VibeModalBody>
<TrackBrowser />
</VibeModalBody>
</VibeModal>
</template>Nested modals
vue
<template>
<!-- Scroll lock is reference-counted — safe to nest -->
<VibeModal v-model:open="outerOpen">
<VibeModalHeader>Outer Modal</VibeModalHeader>
<VibeModalBody>
<VibeButton @click="innerOpen = true">Open Inner</VibeButton>
<VibeModal v-model:open="innerOpen" size="sm">
<VibeModalHeader>Inner Modal</VibeModalHeader>
<VibeModalBody>Escape only closes this one.</VibeModalBody>
</VibeModal>
</VibeModalBody>
</VibeModal>
</template>