Modal
A dialog that overlays the page for focused tasks: confirmations, forms, or detail views. It traps focus while open, locks page scroll, closes on backdrop click or Escape (unless made persistent), and wires up aria-labelledby / aria-describedby from its title and description.
Unlike the menu overlays, a Modal has no trigger sub-component. You control it with v-model. Open it by setting the bound value to true (typically from a button), and it closes itself via the close button, backdrop, or Escape.
Anatomy
Modal ← the dialog; controlled by v-model, sets size/persistence
├── ModalHeader ← title area (position the close button here)
│ ├── ModalTitle ← the heading (wires aria-labelledby)
│ ├── ModalDescription ← supporting text (wires aria-describedby)
│ └── ModalClose ← the × dismiss button
├── ModalContent ← the body
└── ModalFooter ← action buttons (Cancel / Confirm)Every part is optional except Modal itself. Include only what you need. The default slot exposes a close function for wiring footer actions.
Usage
Drive the modal with v-model; open it from a button.
<script setup lang="ts">
import { ref } from "vue";
import {
Modal,
ModalHeader,
ModalTitle,
ModalDescription,
ModalContent,
ModalFooter,
ModalClose,
Button,
} from "@dlbcodes/ui";
import { PhWarning } from "@phosphor-icons/vue";
const open = ref(false);
const deleted = ref(false);
const confirmDelete = (): void => {
deleted.value = true;
open.value = false;
};
</script>
<template>
<div class="flex flex-col items-center gap-3">
<Button variant="destructive" @click="open = true">
Delete project
</Button>
<p v-if="deleted" class="text-sm text-text-tertiary">
Project deleted. (This is just a demo.)
</p>
<Modal v-model="open" size="lg">
<ModalHeader>
<ModalTitle>Delete this project?</ModalTitle>
<ModalDescription>
This action can't be undone.
</ModalDescription>
<ModalClose />
</ModalHeader>
<ModalContent>
<p class="text-sm leading-relaxed text-text-secondary">
"Marketing site" and all of its 24 pages will be permanently
removed.
</p>
</ModalContent>
<ModalFooter>
<Button variant="secondary" @click="open = false">
Cancel
</Button>
<Button variant="destructive" @click="confirmDelete">
Delete project
</Button>
</ModalFooter>
</Modal>
</div>
</template>
On small screens the modal is a bottom sheet: full width, anchored to the bottom of the screen, with rounded top corners. From the md breakpoint up it is a centered dialog. Its height is capped to the visible screen, and it scrolls inside when the content is taller.
With a form
Use ModalFooter for actions and the close slot prop to dismiss from a button. Composed with Field and Input, the modal becomes a focused edit surface.
<script setup lang="ts">
import { ref } from "vue";
import {
Modal,
ModalHeader,
ModalTitle,
ModalDescription,
ModalContent,
ModalFooter,
ModalClose,
Button,
Field,
FieldLabel,
FieldContent,
Input,
Textarea,
} from "@dlbcodes/ui";
const open = ref(false);
const name = ref("");
const email = ref("");
const note = ref("");
const submit = (): void => {
// (mock) would persist here
open.value = false;
};
</script>
<template>
<div>
<Button variant="primary" @click="open = true">New contact</Button>
<Modal v-model="open" size="md">
<ModalHeader>
<ModalTitle>New contact</ModalTitle>
<ModalDescription>
Add someone to your address book.
</ModalDescription>
<ModalClose />
</ModalHeader>
<ModalContent>
<div class="flex flex-col gap-4">
<Field>
<FieldLabel>Name</FieldLabel>
<FieldContent>
<Input v-model="name" placeholder="Jane Doe" />
</FieldContent>
</Field>
<Field>
<FieldLabel>Email</FieldLabel>
<FieldContent>
<Input
v-model="email"
type="email"
placeholder="jane@example.com"
/>
</FieldContent>
</Field>
<Field>
<FieldLabel>Note</FieldLabel>
<FieldContent>
<Textarea
v-model="note"
placeholder="Optional note…"
/>
</FieldContent>
</Field>
</div>
</ModalContent>
<ModalFooter>
<Button variant="secondary" @click="open = false">
Cancel
</Button>
<Button variant="primary" @click="submit">Add contact</Button>
</ModalFooter>
</Modal>
</div>
</template>
Preventing accidental close
Pass before-close to intercept a close and optionally cancel it. It receives the reason the close was attempted ("escape", "backdrop", "close-button", or "programmatic") and returns a boolean (or a promise of one). Return false to keep the modal open.
This is the right tool for forms with unsaved changes: if the user has edited something and tries to dismiss, you can confirm before discarding their work.
<script setup lang="ts">
import { ref, computed } from "vue";
import {
Modal,
ModalHeader,
ModalTitle,
ModalDescription,
ModalContent,
ModalFooter,
ModalClose,
Button,
Field,
FieldLabel,
FieldContent,
Input,
} from "@dlbcodes/ui";
type CloseReason = "escape" | "backdrop" | "close-button" | "programmatic";
const open = ref(false);
const saved = ref({ name: "Daniel Lobo", title: "Frontend Engineer" });
const form = ref({ ...saved.value });
const isDirty = computed(
() =>
form.value.name !== saved.value.name ||
form.value.title !== saved.value.title,
);
// Track the last close attempt + whether it was vetoed, to show in the demo.
const lastReason = ref<CloseReason | null>(null);
const lastVetoed = ref(false);
const openModal = (): void => {
form.value = { ...saved.value };
lastReason.value = null;
lastVetoed.value = false;
open.value = true;
};
// before-close receives the reason; we record it and veto when dirty.
const confirmDiscard = (reason: CloseReason): boolean => {
lastReason.value = reason;
if (!isDirty.value) {
lastVetoed.value = false;
return true;
}
const discard = window.confirm("You have unsaved changes. Discard them?");
lastVetoed.value = !discard;
return discard;
};
const save = (): void => {
saved.value = { ...form.value };
open.value = false;
};
</script>
<template>
<div class="flex flex-col items-center gap-3">
<Button variant="primary" @click="openModal">Edit profile</Button>
<!-- Surface the close reason so the feature is visible -->
<p v-if="lastReason" class="text-sm text-text-tertiary">
Last close attempt:
<code class="text-text-secondary">{{ lastReason }}</code>
<span v-if="lastVetoed"> — vetoed (kept open)</span>
</p>
<Modal v-model="open" :before-close="confirmDiscard" size="md">
<ModalHeader>
<ModalTitle>Edit profile</ModalTitle>
<ModalDescription>
Try dismissing via Escape, the backdrop, or the ✕ — the
reason shows below, and edits are protected.
</ModalDescription>
<ModalClose />
</ModalHeader>
<ModalContent>
<div class="flex flex-col gap-4">
<Field>
<FieldLabel>Name</FieldLabel>
<FieldContent>
<Input v-model="form.name" />
</FieldContent>
</Field>
<Field>
<FieldLabel>Title</FieldLabel>
<FieldContent>
<Input v-model="form.title" />
</FieldContent>
</Field>
</div>
</ModalContent>
<ModalFooter>
<Button variant="secondary" @click="open = false">
Cancel
</Button>
<Button variant="primary" :disabled="!isDirty" @click="save">
Save changes
</Button>
</ModalFooter>
</Modal>
</div>
</template>
<Modal v-model="open" :before-close="confirmDiscard">
<!-- ... -->
</Modal>
<script setup>
const confirmDiscard = (reason) => {
if (!isDirty.value) return true; // nothing to lose, allow
return window.confirm("Discard your changes?"); // true closes, false stays
};
</script>The close event also reports the reason, so you can branch on how a dialog was dismissed:
<Modal v-model="open" @close="(reason) => track('modal_closed', { reason })">Sizes
The size prop controls the max width: sm through 5xl, plus full.
<script setup lang="ts">
import { ref } from "vue";
import {
Modal,
ModalHeader,
ModalTitle,
ModalDescription,
ModalContent,
ModalFooter,
ModalClose,
Button,
} from "@dlbcodes/ui";
type Demo = "sm" | "lg" | "2xl";
const active = ref<Demo | null>(null);
const open = (d: Demo): void => {
active.value = d;
};
const close = (): void => {
active.value = null;
};
</script>
<template>
<div class="flex flex-wrap gap-3">
<Button variant="secondary" @click="open('sm')">Confirm (sm)</Button>
<Button variant="secondary" @click="open('lg')">Edit form (lg)</Button>
<Button variant="secondary" @click="open('2xl')">Details (2xl)</Button>
</div>
<!-- sm: a tight confirmation -->
<Modal :model-value="active === 'sm'" size="sm" @update:model-value="close">
<ModalHeader>
<ModalTitle>Leave without saving?</ModalTitle>
<ModalDescription>
Your draft will be kept for 7 days.
</ModalDescription>
<ModalClose />
</ModalHeader>
<ModalFooter>
<Button variant="secondary" @click="close">Keep editing</Button>
<Button variant="primary" @click="close">Leave</Button>
</ModalFooter>
</Modal>
<!-- lg: room for a short form -->
<Modal :model-value="active === 'lg'" size="lg" @update:model-value="close">
<ModalHeader>
<ModalTitle>Invite teammates</ModalTitle>
<ModalDescription>
They'll get an email invite to join your workspace.
</ModalDescription>
<ModalClose />
</ModalHeader>
<ModalContent>
<p class="text-sm text-text-secondary">
A larger width gives a form comfortable room to breathe without
feeling cramped.
</p>
</ModalContent>
<ModalFooter>
<Button variant="secondary" @click="close">Cancel</Button>
<Button variant="primary" @click="close">Send invites</Button>
</ModalFooter>
</Modal>
<!-- 2xl: wide, for detail/review content -->
<Modal
:model-value="active === '2xl'"
size="2xl"
@update:model-value="close"
>
<ModalHeader>
<ModalTitle>Review changes</ModalTitle>
<ModalDescription>
12 files changed across 3 directories.
</ModalDescription>
<ModalClose />
</ModalHeader>
<ModalContent>
<p class="text-sm text-text-secondary">
The widest sizes suit dense content: diffs, tables, or
side-by-side detail views where horizontal space matters.
</p>
</ModalContent>
<ModalFooter>
<Button variant="secondary" @click="close">Close</Button>
<Button variant="primary" @click="close">Approve</Button>
</ModalFooter>
</Modal>
</template>
Persistent
Set persistent to prevent closing via backdrop click or Escape. The user must take an explicit action (the close button or a footer action still work). Use sparingly, for cases where dismissing accidentally would lose work or skip a required choice.
For unsaved-changes protection specifically, prefer before-close over persistent: it still lets the user dismiss, but confirms first, rather than trapping them.
<script setup lang="ts">
import { ref } from "vue";
import {
Modal,
ModalHeader,
ModalTitle,
ModalDescription,
ModalContent,
ModalFooter,
Button,
} from "@dlbcodes/ui";
const open = ref(false);
const accepted = ref(false);
const accept = (): void => {
accepted.value = true;
open.value = false;
};
</script>
<template>
<div class="flex flex-col items-center gap-3">
<Button variant="secondary" @click="open = true">
Review updated terms
</Button>
<p v-if="accepted" class="text-sm text-text-tertiary">
Thanks, your acceptance was recorded. (Demo only.)
</p>
<Modal v-model="open" persistent size="md">
<ModalHeader>
<ModalTitle>We've updated our terms</ModalTitle>
<ModalDescription>
Please review and accept the updated Terms of Service to
keep using your account.
</ModalDescription>
</ModalHeader>
<ModalContent>
<p class="text-sm leading-relaxed text-text-secondary">
We've clarified how data is stored and added details about
the new collaboration features. Nothing changes about how
your existing content is handled. You can read the full
terms any time from your account settings.
</p>
</ModalContent>
<ModalFooter>
<Button variant="primary" @click="accept">
Accept and continue
</Button>
</ModalFooter>
</Modal>
</div>
</template>
Appearance
The dialog uses the theme's --popover surface with a --border-subtle border, and its corners follow --radius. The backdrop is a translucent black in both light and dark mode. See Theming.
Props
Modal
| Prop | Type | Default | Description |
|---|---|---|---|
modelValue | boolean | — | Open state. Use with v-model. Required. |
size | "sm" | "md" | "lg" | "xl" | "2xl" | "3xl" | "4xl" | "5xl" | "full" | "md" | Max width of the dialog. |
closeOnBackdrop | boolean | true | Whether clicking the backdrop closes the modal. |
persistent | boolean | false | Prevents closing via backdrop or Escape (the close button still works). |
beforeClose | (reason) => boolean | Promise<boolean> | — | Runs before any close. Return false to veto and keep the modal open. Receives the reason. |
class | string | — | Classes merged onto the dialog. |
Events: update:modelValue (boolean) and close (the close reason: "escape" \| "backdrop" \| "close-button" \| "programmatic"). The default slot exposes close.
Sub-components
| Component | Purpose |
|---|---|
ModalHeader | Title-area container; position ModalClose inside it. |
ModalTitle | The heading. Registers aria-labelledby on the dialog. |
ModalDescription | Supporting text. Registers aria-describedby. |
ModalContent | The scrollable body. |
ModalFooter | Right-aligned action row. |
ModalClose | The × button (an icon Button). Routes through before-close. |
All sub-components accept class.
Accessibility
- The dialog has
role="dialog"andaria-modal="true".ModalTitleandModalDescriptionautomatically registeraria-labelledby/aria-describedby, so include aModalTitlefor an accessible name. - Focus is trapped within the dialog while open and restored when it closes. A dialog with nothing focusable in it (only text) keeps focus on the dialog itself instead of failing.
- Page scroll is locked while open, including when the modal is rendered open from the start. The lock is released if the modal is removed while open, for example on a route change.
Escapecloses the modal (unlesspersistentor vetoed bybefore-close);ModalCloseroutes throughbefore-closetoo.- Content is only mounted while the modal is open.