Skip to content

@carrot/design-vue-modals

Vue 3 modal and drawer components for the Carrot Design System. Focus trapping, scroll locking (with nested modal support), teleport, and CSS-driven enter/exit animations.

Installation

ts
import { CarrotModal, CarrotModalHeader, CarrotModalBody, CarrotModalFooter, useModal } from "@carrot/design-vue-modals";

Requires the CSS layer from @carrot/design-components-modals.

Components

CarrotModal

Root modal container with backdrop, animation lifecycle, focus trap, and scroll lock.

Usage

vue
<!-- Basic -->
<CarrotModal v-model:open="isOpen">
  <CarrotModalHeader>Edit Profile</CarrotModalHeader>
  <CarrotModalBody>Form content here</CarrotModalBody>
  <CarrotModalFooter>
    <CarrotButton variant="ghost" @click="isOpen = false">Cancel</CarrotButton>
    <CarrotButton @click="save">Save</CarrotButton>
  </CarrotModalFooter>
</CarrotModal>

<!-- Sizes -->
<CarrotModal v-model:open="isOpen" size="sm">Small modal</CarrotModal>
<CarrotModal v-model:open="isOpen" size="lg">Large modal</CarrotModal>
<CarrotModal v-model:open="isOpen" size="xl">Extra large</CarrotModal>
<CarrotModal v-model:open="isOpen" size="full">Full screen</CarrotModal>

<!-- Danger confirmation -->
<CarrotModal v-model:open="confirmOpen" variant="danger" centered size="sm">
  <CarrotModalHeader>
    Delete Item
    <template #description>This action cannot be undone.</template>
  </CarrotModalHeader>
  <CarrotModalBody>Are you sure you want to delete this item?</CarrotModalBody>
  <CarrotModalFooter>
    <CarrotButton variant="ghost" @click="confirmOpen = false">Cancel</CarrotButton>
    <CarrotButton variant="danger" @click="handleDelete">Delete</CarrotButton>
  </CarrotModalFooter>
</CarrotModal>

<!-- Drawer (right-side panel) -->
<CarrotModal v-model:open="drawerOpen" drawer size="md">
  <CarrotModalHeader>Details</CarrotModalHeader>
  <CarrotModalBody>Drawer content</CarrotModalBody>
</CarrotModal>

<!-- Drawer from left (e.g. navigation) -->
<CarrotModal v-model:open="navOpen" drawer drawer-side="left" size="sm">
  <CarrotModalBody>Navigation menu</CarrotModalBody>
</CarrotModal>

<!-- Drawer without backdrop dimming -->
<CarrotModal v-model:open="panelOpen" drawer no-backdrop size="md">
  <CarrotModalBody>Side panel content</CarrotModalBody>
</CarrotModal>

<!-- Close guard (unsaved changes) -->
<CarrotModal v-model:open="isOpen" :before-close="confirmDiscard">
  <CarrotModalHeader>Edit Profile</CarrotModalHeader>
  <CarrotModalBody>Form content here</CarrotModalBody>
</CarrotModal>

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

<!-- No close button, no backdrop close -->
<CarrotModal v-model:open="isOpen" no-close :close-on-backdrop="false">
  <CarrotModalHeader>Required Action</CarrotModalHeader>
  <CarrotModalBody>Complete this before continuing.</CarrotModalBody>
</CarrotModal>

<!-- Custom initial focus -->
<CarrotModal v-model:open="isOpen" initial-focus="#name-input">
  <CarrotModalBody>
    <CarrotInput id="name-input" v-model="name" label="Name" />
  </CarrotModalBody>
</CarrotModal>

<!-- Scoped slot for close -->
<CarrotModal v-model:open="isOpen" v-slot="{ close }">
  <CarrotModalBody>
    <CarrotButton @click="close">Done</CarrotButton>
  </CarrotModalBody>
</CarrotModal>

<!-- Disable teleport (for SSR or special layouts) -->
<CarrotModal v-model:open="isOpen" :teleport="false">
  ...
</CarrotModal>

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
drawerbooleanEdge-docked drawer mode
drawerSideModalDrawerSide"right"Which edge the drawer slides from (left · right). Only applies when drawer is true
noBackdropbooleanHide backdrop overlay (drawer still traps focus)
beforeClose() => boolean | Promise<boolean>Guard called before close. Return false to prevent
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

CarrotModalHeader

Header section with title, optional description, actions slot, and auto-wired close button.

vue
<CarrotModalHeader>
  Modal Title
  <template #description>Optional subtitle text</template>
  <template #actions="{ close }">
    <CarrotButton variant="ghost" icon @click="close"><MinimiseIcon /></CarrotButton>
  </template>
</CarrotModalHeader>

<!-- Screen-reader only -->
<CarrotModalHeader sr-only>Accessible title</CarrotModalHeader>

The close button is auto-rendered from the parent CarrotModal'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

CarrotModalBody

Scrollable content area.

vue
<CarrotModalBody>
  <p>Main modal content.</p>
</CarrotModalBody>

CarrotModalFooter

Footer section for actions. Supports split layout.

vue
<!-- Standard (right-aligned) -->
<CarrotModalFooter>
  <CarrotButton variant="ghost" @click="close">Cancel</CarrotButton>
  <CarrotButton @click="save">Save</CarrotButton>
</CarrotModalFooter>

<!-- Split (space-between) -->
<CarrotModalFooter split>
  <CarrotButton variant="danger">Delete</CarrotButton>
  <div>
    <CarrotButton variant="ghost">Cancel</CarrotButton>
    <CarrotButton>Confirm</CarrotButton>
  </div>
</CarrotModalFooter>

Props

PropTypeDefaultDescription
splitbooleanfalsePush first/last children to opposite ends

Composables

useModal(initialOpen?)

Simple reactive state helper for controlling a modal.

ts
import { useModal } from "@carrot/design-vue-modals";

const confirmModal = useModal();

function onDelete() {
  confirmModal.open();
}
vue
<CarrotModal v-model:open="confirmModal.isOpen.value">
  ...
</CarrotModal>

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 "@carrot/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 "@carrot/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
@carrot/design-components-modalsCSS tokens + generated styles

Build

bash
npm run build

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


Usage Guide

Setup

ts
import { CarrotModal, CarrotModalHeader, CarrotModalBody, CarrotModalFooter, useModal } from "@carrot/design-vue-modals";
import "@carrot/design-components-modals";

CarrotModal

Edit modal with form

vue
<script setup lang="ts">
import { CarrotModal, CarrotModalHeader, CarrotModalBody, CarrotModalFooter } from "@carrot/design-vue-modals";
import { CarrotButton } from "@carrot/design-vue-buttons";
import { CarrotInput } from "@carrot/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>
  <CarrotButton @click="isOpen = true">Edit Profile</CarrotButton>

  <CarrotModal v-model:open="isOpen" size="md" initial-focus="#name-input">
    <CarrotModalHeader>
      Edit Profile
      <template #description>Update your public information.</template>
    </CarrotModalHeader>
    <CarrotModalBody>
      <CarrotInput id="name-input" v-model="name" label="Display Name" required />
    </CarrotModalBody>
    <CarrotModalFooter>
      <CarrotButton variant="ghost" @click="isOpen = false">Cancel</CarrotButton>
      <CarrotButton @click="save">Save Changes</CarrotButton>
    </CarrotModalFooter>
  </CarrotModal>
</template>

Confirmation dialog with useModal

vue
<script setup lang="ts">
import { CarrotModal, CarrotModalHeader, CarrotModalBody, CarrotModalFooter, useModal } from "@carrot/design-vue-modals";
import { CarrotButton } from "@carrot/design-vue-buttons";

const deleteModal = useModal();

const emit = defineEmits<{ deleted: [] }>();

async function handleDelete() {
  await api.deleteItem(props.id);
  deleteModal.close();
  emit("deleted");
}
</script>

<template>
  <CarrotButton variant="danger" @click="deleteModal.open()">Delete</CarrotButton>

  <CarrotModal v-model:open="deleteModal.isOpen.value" variant="danger" centered size="sm">
    <CarrotModalHeader>
      Delete Item
      <template #description>This action cannot be undone.</template>
    </CarrotModalHeader>
    <CarrotModalBody>
      Are you sure you want to permanently delete this item?
    </CarrotModalBody>
    <CarrotModalFooter>
      <CarrotButton variant="ghost" @click="deleteModal.close()">Cancel</CarrotButton>
      <CarrotButton variant="danger" @click="handleDelete">Delete</CarrotButton>
    </CarrotModalFooter>
  </CarrotModal>
</template>

Drawer (right-side panel)

vue
<script setup lang="ts">
import { CarrotModal, CarrotModalHeader, CarrotModalBody } from "@carrot/design-vue-modals";
import { ref } from "vue";

const drawerOpen = ref(false);
defineProps<{ trackId: string }>();
</script>

<template>
  <CarrotButton @click="drawerOpen = true">View Details</CarrotButton>

  <CarrotModal v-model:open="drawerOpen" drawer size="lg">
    <CarrotModalHeader>Track Details</CarrotModalHeader>
    <CarrotModalBody>
      <TrackDetailView :id="trackId" />
    </CarrotModalBody>
  </CarrotModal>
</template>

Drawer from left (navigation)

vue
<script setup lang="ts">
import { CarrotModal, CarrotModalBody } from "@carrot/design-vue-modals";
import { CarrotMenu, CarrotMenuItem, CarrotMenuGroup } from "@carrot/design-vue-menus";
import { CarrotButton } from "@carrot/design-vue-buttons";
import { ref } from "vue";

const navOpen = ref(false);
</script>

<template>
  <CarrotButton variant="ghost" icon aria-label="Open menu" @click="navOpen = true">
    <MenuIcon :size="20" />
  </CarrotButton>

  <CarrotModal v-model:open="navOpen" drawer drawer-side="left" size="sm"
    aria-label="Navigation">
    <CarrotModalBody>
      <CarrotMenu nav aria-label="Main navigation">
        <CarrotMenuGroup label="Pages">
          <CarrotMenuItem :to="{ name: 'dashboard' }" label="Dashboard" @click="navOpen = false" />
          <CarrotMenuItem :to="{ name: 'settings' }" label="Settings" @click="navOpen = false" />
        </CarrotMenuGroup>
      </CarrotMenu>
    </CarrotModalBody>
  </CarrotModal>
</template>

Drawer without backdrop

vue
<template>
  <!-- Panel overlays content without dimming the page behind it -->
  <CarrotModal v-model:open="panelOpen" drawer no-backdrop size="md">
    <CarrotModalHeader>Filters</CarrotModalHeader>
    <CarrotModalBody>
      <FilterPanel />
    </CarrotModalBody>
  </CarrotModal>
</template>

Close guard (unsaved changes)

vue
<script setup lang="ts">
import { CarrotModal, CarrotModalHeader, CarrotModalBody, CarrotModalFooter } from "@carrot/design-vue-modals";
import { CarrotButton } from "@carrot/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>
  <CarrotModal v-model:open="isOpen" :before-close="confirmDiscard">
    <CarrotModalHeader>Edit Profile</CarrotModalHeader>
    <CarrotModalBody>
      <CarrotInput v-model="name" label="Name" @update:model-value="isDirty = true" />
    </CarrotModalBody>
    <CarrotModalFooter>
      <CarrotButton variant="ghost" @click="isOpen = false">Cancel</CarrotButton>
      <CarrotButton @click="save">Save</CarrotButton>
    </CarrotModalFooter>
  </CarrotModal>
</template>

Common Patterns

vue
<template>
  <CarrotModal v-model:open="isOpen" no-close v-slot="{ close }">
    <CarrotModalBody>
      <p>Complete this step to continue.</p>
      <CarrotButton @click="completeAndClose(close)">Continue</CarrotButton>
    </CarrotModalBody>
  </CarrotModal>
</template>

<script setup lang="ts">
function completeAndClose(close: () => void) {
  doSomething();
  close();
}
</script>

Full-screen modal for mobile

vue
<template>
  <CarrotModal v-model:open="isOpen" size="full">
    <CarrotModalHeader>Browse Tracks</CarrotModalHeader>
    <CarrotModalBody>
      <TrackBrowser />
    </CarrotModalBody>
  </CarrotModal>
</template>

Nested modals

vue
<template>
  <!-- Scroll lock is reference-counted — safe to nest -->
  <CarrotModal v-model:open="outerOpen">
    <CarrotModalHeader>Outer Modal</CarrotModalHeader>
    <CarrotModalBody>
      <CarrotButton @click="innerOpen = true">Open Inner</CarrotButton>
      <CarrotModal v-model:open="innerOpen" size="sm">
        <CarrotModalHeader>Inner Modal</CarrotModalHeader>
        <CarrotModalBody>Escape only closes this one.</CarrotModalBody>
      </CarrotModal>
    </CarrotModalBody>
  </CarrotModal>
</template>

Carrot