Skip to content

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

<!-- Seamless (no section borders) -->
<VibeModal v-model:open="isOpen" seamless>
  <VibeModalBody>Clean look</VibeModalBody>
</VibeModal>

<!-- No close button -->
<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

PropTypeDefaultDescription
openbooleanVisibility (v-model:open)
sizeModalSize"md"sm · md · lg · xl · full
variantModalVariantStyle variant (e.g. "danger")
seamlessbooleanRemove section borders
centeredbooleanCentre-align body content
drawerbooleanRight-side drawer mode
closeOnBackdropbooleantrueClose on backdrop click
closeOnEscapebooleantrueClose on Escape key
trapFocusbooleantrueTrap focus within modal
lockScrollbooleantrueLock body scroll
teleportstring | false"body"Teleport target
ariaLabelstringAccessible label (overrides titleId)
initialFocusstring | "none"CSS selector for initial focus
noClosebooleanfalseHide close button in header

Events

EventPayloadDescription
update:openbooleanOpen state changed
openedEnter animation complete
closedExit animation complete

Scoped Slot

PropertyTypeDescription
close() => voidClose 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 (visually hidden but accessible) -->
<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

PropTypeDefaultDescription
srOnlybooleanfalseVisually hide the header

Slots

SlotDescription
defaultTitle content
descriptionSubtitle / description
actionsCustom header actions (receives { close })
close-iconCustom 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

PropTypeDefaultDescription
splitbooleanfalsePush 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", // or "none" to skip
});

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

PackagePurpose
@vibe-labs/design-components-modalsCSS tokens + generated styles

Build

bash
npm run build

Built with Vite + vite-plugin-dts. Outputs ES module with TypeScript declarations.