Component
Vue Modal Dialog Component
<DomDialog>A modal built on the native HTML <dialog> element — top-layer rendering, native stacking, scroll lock, and backdrop styling without z-index hacks.
Playground
Try every prop live
Dialog playground
Edit any prop on the right — open the dialog with the trigger to preview title, description, and footer actions. Source shows the SFC you would write; Data shows the live props object.
Properties
npm install @getdom/studio<script setup>
import '@getdom/studio/style.css';
import { reactive } from 'vue';
import { DomDialog, DomButton } from '@getdom/studio';
const data = reactive({
modelValue: false,
static: false,
backdrop: true,
size: 'md',
width: '',
height: '',
title: 'Dialog title',
description: 'Supporting text shown under the heading.',
});
defineExpose({ data });
</script>
<template>
<DomDialog
v-bind="data"
@update:modelValue="data.modelValue = $event"
>
<template #trigger>
<DomButton variant="primary">Open dialog</DomButton>
</template>
<p class="text-sm text-muted-fg">Dialog body content.</p>
<template #footer>
<DomButton variant="secondary" data-close>Cancel</DomButton>
<DomButton data-close>Confirm</DomButton>
</template>
</DomDialog>
</template>
Demo
Delete confirmation
A destructive action pattern — title, description, body copy, and footer buttons bound with v-model.
npm install @getdom/studio<script setup>
import '@getdom/studio/style.css';
import { ref } from 'vue';
import { DomDialog, DomButton } from '@getdom/studio';
const open = ref(false);
</script>
<template>
<DomButton variant="danger" @click="open = true">Delete project</DomButton>
<DomDialog
v-model="open"
title="Delete project?"
description="This will permanently delete the project and all of its data."
>
<p class="text-sm text-muted-fg">There is no undo. Type the project name to confirm.</p>
<input
class="mt-3 h-10 w-full rounded-full border border-border bg-canvas px-4 text-sm"
placeholder="my-project"
/>
<template #footer>
<DomButton variant="secondary" @click="open = false">Cancel</DomButton>
<DomButton variant="danger" @click="open = false">Delete</DomButton>
</template>
</DomDialog>
</template>
Demo
Scrollable terms
A long-form agreement pattern that keeps the dialog footer visible while the terms scroll inside the dialog body.
npm install @getdom/studio<script setup>
import '@getdom/studio/style.css';
import { nextTick, ref, watch } from 'vue';
import { DomDialog, DomButton } from '@getdom/studio';
const open = ref(false);
const canAccept = ref(false);
const terms = ref(null);
const sections = [
{
title: 'Account access',
body: 'You are responsible for keeping your account details accurate and for protecting access to your workspace. Notify the service team if you believe your account has been used without permission.',
},
{
title: 'Acceptable use',
body: 'Do not use the service to interfere with other customers, attempt to bypass security controls, scrape private data, or upload content that you do not have permission to process.',
},
{
title: 'Customer content',
body: 'You retain ownership of your content. The service may process, store, and transmit that content only as needed to provide the features you choose to use.',
},
{
title: 'Service changes',
body: 'Features may change as the product evolves. When a material change affects your rights or obligations, updated terms will be made available before they take effect.',
},
{
title: 'Billing',
body: 'Paid plans renew automatically unless cancelled before the renewal date. Taxes, usage charges, and subscription changes may be reflected on future invoices.',
},
{
title: 'Privacy',
body: 'Personal data is handled according to the privacy notice. Administrative users should make sure their teams understand what information is submitted to the service.',
},
{
title: 'Availability',
body: 'The service is designed for reliable access, but planned maintenance, emergency repairs, third-party outages, and network conditions may affect availability from time to time.',
},
{
title: 'Termination',
body: 'Either party may end use of the service according to the plan terms. After termination, access may be limited and retained data may be deleted after the applicable retention period.',
},
];
function updateScrollState() {
const el = terms.value;
if (!el) return;
canAccept.value = el.scrollTop + el.clientHeight >= el.scrollHeight - 8;
}
watch(open, async (isOpen) => {
if (!isOpen) return;
canAccept.value = false;
await nextTick();
if (terms.value) terms.value.scrollTop = 0;
updateScrollState();
});
function acceptTerms() {
if (!canAccept.value) return;
open.value = false;
}
</script>
<template>
<DomButton variant="primary" @click="open = true">Review terms</DomButton>
<DomDialog
v-model="open"
title="Terms and conditions"
description="Read the full agreement before accepting."
>
<div
ref="terms"
class="max-h-[min(52vh,22rem)] overflow-y-auto rounded-xl border border-border bg-secondary/30 p-4 pr-3 text-sm leading-6 text-muted-fg"
@scroll="updateScrollState"
>
<div class="space-y-5">
<section v-for="section in sections" :key="section.title" class="space-y-1">
<h3 class="text-sm font-semibold text-canvas-fg">{{ section.title }}</h3>
<p>{{ section.body }}</p>
</section>
<p class="border-t border-border pt-5 text-canvas-fg">
By accepting, you confirm that you have reviewed the terms above and are authorised to agree on behalf of your workspace.
</p>
</div>
</div>
<template #footer>
<p class="mr-auto text-xs text-muted-fg">
{{ canAccept ? 'Ready to accept.' : 'Scroll to the end to continue.' }}
</p>
<DomButton variant="secondary" @click="open = false">Cancel</DomButton>
<DomButton :disabled="!canAccept" @click="acceptTerms">Accept</DomButton>
</template>
</DomDialog>
</template>
Demo
Wide edit form
A near-full-page dialog for dense editorial workflows — custom width and height keep form fields scrollable while the action footer stays available.
No post changes saved yet.
npm install @getdom/studio<script setup>
import '@getdom/studio/style.css';
import { ref } from 'vue';
import {
DomButton,
DomCheckbox,
DomCombobox,
DomDialog,
DomForm,
DomSelect,
DomStatusPill,
DomTextInput,
DomTextareaInput,
DomToggle,
} from '@getdom/studio';
const initialPost = {
title: 'Launching the summer product notes',
slug: 'summer-product-notes',
excerpt: 'A concise editor note that introduces the upcoming product improvements and release timing.',
body: 'The next product update focuses on faster project setup, clearer review queues, and better publishing controls for distributed teams.',
status: 'scheduled',
owner: 'maya-patel',
category: 'product-updates',
publishAt: '2 Jul 2026, 09:30',
seoTitle: 'Summer product notes and release updates',
seoDescription: 'Read the latest product notes, launch timing, and editorial guidance for the summer release.',
featured: true,
allowComments: false,
};
const statusOptions = [
{ label: 'Draft', value: 'draft', description: 'Keep hidden from reviewers and subscribers.' },
{ label: 'In review', value: 'review', description: 'Ready for editorial and legal approval.' },
{ label: 'Scheduled', value: 'scheduled', description: 'Queued for the selected publish date.' },
{ label: 'Published', value: 'published', description: 'Visible on the public site.' },
];
const ownerOptions = [
{ label: 'Maya Patel', value: 'maya-patel' },
{ label: 'Theo James', value: 'theo-james' },
{ label: 'Anika Bose', value: 'anika-bose' },
{ label: 'Rory Chen', value: 'rory-chen' },
];
const categoryOptions = [
{ label: 'Product updates', value: 'product-updates' },
{ label: 'Engineering', value: 'engineering' },
{ label: 'Customer stories', value: 'customer-stories' },
{ label: 'Company news', value: 'company-news' },
];
const open = ref(false);
const draft = ref(clonePost(initialPost));
const lastSaved = ref('No post changes saved yet.');
/**
* Create a detached editable post object.
*
* @param {Record<string, unknown>} post Source post data.
* @returns {Record<string, unknown>} Cloned post data for form editing.
*/
function clonePost(post) {
return { ...post };
}
/**
* Resolve the display label for a saved option value.
*
* @param {Array<{ label: string, value: string }>} options Selectable options.
* @param {string} value Stored option value.
* @returns {string} Display label or raw value fallback.
*/
function optionLabel(options, value) {
return options.find((option) => option.value === value)?.label || value;
}
/**
* Restore the form to its original example data.
*
* @returns {void}
*/
function resetPost() {
draft.value = clonePost(initialPost);
}
/**
* Save the edited post values and close the dialog.
*
* @param {{ values: Record<string, unknown> }} payload Form submission payload.
* @returns {void}
*/
function savePost({ values }) {
const status = optionLabel(statusOptions, String(values.status || 'draft')).toLowerCase();
lastSaved.value = `${values.title || 'Post'} saved as ${status}.`;
open.value = false;
}
</script>
<template>
<div class="flex flex-col items-center gap-3">
<DomButton variant="primary" @click="open = true">Edit post</DomButton>
<p class="text-sm text-muted-fg">{{ lastSaved }}</p>
</div>
<DomDialog
v-model="open"
width="95vw"
height="95vh"
title="Edit post"
description="Update editorial content, publishing state, and search metadata in a near-full-page dialog."
>
<DomForm
id="edit-post-dialog-form"
v-model="draft"
class="space-y-6"
@submit="savePost"
>
<div class="flex flex-wrap items-start justify-between gap-3 border-b border-border pb-4">
<div class="space-y-1">
<p class="text-sm font-medium text-canvas-fg">Publishing workflow</p>
<p class="text-xs leading-relaxed text-muted-fg">Draft content and review settings stay together so editors can make a final pass quickly.</p>
</div>
<DomStatusPill tone="warning" label="Scheduled" />
</div>
<div class="grid gap-5 lg:grid-cols-[minmax(0,1.35fr)_minmax(18rem,0.85fr)]">
<div class="space-y-4">
<DomTextInput
name="title"
label="Title"
placeholder="Launching the summer product notes"
required
/>
<div class="grid gap-4 sm:grid-cols-2">
<DomTextInput
name="slug"
label="Slug"
placeholder="summer-product-notes"
required
/>
<DomTextInput
name="publishAt"
label="Publish date"
placeholder="2 Jul 2026, 09:30"
/>
</div>
<DomTextareaInput
name="excerpt"
label="Excerpt"
:rows="3"
placeholder="Short summary shown in lists and feeds."
/>
<DomTextareaInput
name="body"
label="Editorial note"
:rows="7"
placeholder="Write the post body..."
/>
</div>
<div class="space-y-4">
<DomSelect
name="status"
label="Status"
:options="statusOptions"
required
/>
<DomCombobox
name="owner"
label="Owner"
:options="ownerOptions"
placeholder="Choose an owner"
/>
<DomSelect
name="category"
label="Category"
:options="categoryOptions"
/>
<div class="space-y-3 border-t border-border pt-4">
<DomToggle
name="featured"
label="Feature this post"
description="Highlight it in the editorial index."
/>
<DomCheckbox
name="allowComments"
label="Allow comments"
description="Let signed-in customers respond after publishing."
/>
</div>
</div>
</div>
<div class="grid gap-4 border-t border-border pt-4 md:grid-cols-2">
<DomTextInput
name="seoTitle"
label="SEO title"
placeholder="Search result title"
/>
<DomTextareaInput
name="seoDescription"
label="SEO description"
:rows="2"
placeholder="Search result description"
/>
</div>
</DomForm>
<template #footer>
<p class="mr-auto hidden text-xs text-muted-fg sm:block">Changes are saved when the form submits.</p>
<DomButton variant="ghost" type="button" @click="resetPost">Reset</DomButton>
<DomButton variant="secondary" type="button" @click="open = false">Cancel</DomButton>
<DomButton type="submit" form="edit-post-dialog-form">Save changes</DomButton>
</template>
</DomDialog>
</template>
Companion
Contextual AI assistant
A controlled companion beside a wide editorial dialog. On narrow screens it covers the main panel, consumes the first Escape press, and returns focus to the AI chat trigger when closed. Suggestions remain application-owned and update the same form setter used by manual edits.
npm install @getdom/studio<script setup>
import '@getdom/studio/style.css';
import { ref } from 'vue';
import {
DomButton,
DomDialog,
DomIconButton,
DomStatusPill,
DomTextInput,
DomTextareaComposer,
DomTextareaInput,
} from '@getdom/studio';
const open = ref(false);
const assistantOpen = ref(false);
const title = ref('A practical guide to product onboarding');
const plan = ref('Explain the first-run checklist and include one worked example.');
const draft = ref('');
const suggestionStatus = ref('pending');
const messages = ref([
{
id: 'assistant-1',
role: 'assistant',
text: 'I can see the current title, audience and article plan. The plan would be clearer with a short section on measuring activation.',
},
]);
/**
* Update the article plan through the same setter used by manual edits.
*
* @param {string} value Next plan text.
* @returns {void}
*/
function updatePlan(value) {
plan.value = value;
}
/**
* Apply the pending assistant suggestion to the editable plan.
*
* @returns {void}
*/
function acceptSuggestion() {
if (suggestionStatus.value !== 'pending') return;
const addition = 'Add an activation section covering the first meaningful action and one measurable success signal.';
updatePlan(plan.value.trim() ? `${plan.value.trim()}\n${addition}` : addition);
suggestionStatus.value = 'accepted';
}
/**
* Retain the suggestion in the thread while marking it dismissed.
*
* @returns {void}
*/
function dismissSuggestion() {
if (suggestionStatus.value !== 'pending') return;
suggestionStatus.value = 'dismissed';
}
/**
* Add a local user message and a deterministic assistant reply for the example.
*
* @param {string} value Composer content.
* @returns {void}
*/
function sendMessage(value) {
const content = value.trim();
if (!content) return;
messages.value.push(
{ id: `user-${messages.value.length}`, role: 'user', text: content },
{ id: `assistant-${messages.value.length + 1}`, role: 'assistant', text: 'I would keep the existing direction and make the success measure explicit in the closing checklist.' },
);
draft.value = '';
}
</script>
<template>
<DomButton @click="open = true">Open article brief</DomButton>
<DomDialog
v-model="open"
v-model:companion-open="assistantOpen"
width="56rem"
height="min(42rem, calc(100dvh - 2rem))"
companion-width="24rem"
companion-label="Article assistant"
:footer="false"
>
<template #header="{ titleId, descriptionId, companionOpen, companionTriggerProps, toggleCompanion }">
<header class="-mx-6 -mt-6 flex shrink-0 items-start justify-between gap-4 border-b border-border px-6 py-5">
<div class="min-w-0">
<div class="mb-2 flex flex-wrap items-center gap-2">
<DomStatusPill label="Draft" tone="neutral" />
<span class="text-xs text-muted-fg">Article brief</span>
</div>
<h2 :id="titleId" class="truncate text-xl font-semibold tracking-tight text-canvas-fg">Product onboarding</h2>
<p :id="descriptionId" class="mt-1 text-sm text-muted-fg">Review the brief and ask the assistant to suggest focused changes.</p>
</div>
<DomButton
v-bind="companionTriggerProps"
size="sm"
:variant="companionOpen ? 'primary' : 'secondary'"
@click="toggleCompanion"
>
AI chat
</DomButton>
</header>
</template>
<div class="grid gap-5 pt-4 lg:grid-cols-[minmax(0,1fr)_16rem]">
<div class="space-y-5">
<DomTextInput v-model="title" label="Suggested title" />
<DomTextareaInput
:model-value="plan"
label="Article plan"
:rows="8"
@update:model-value="updatePlan"
/>
</div>
<aside class="space-y-3 rounded-xl border border-border bg-secondary/35 p-4">
<p class="text-xs font-semibold uppercase tracking-wider text-muted-fg">Context</p>
<div>
<p class="text-xs text-muted-fg">Audience</p>
<p class="mt-1 text-sm text-canvas-fg">Product teams improving trial activation</p>
</div>
<div>
<p class="text-xs text-muted-fg">Reader goal</p>
<p class="mt-1 text-sm text-canvas-fg">Build a useful first-run experience without adding noise</p>
</div>
</aside>
</div>
<template #footer>
<DomButton variant="ghost" @click="open = false">Close</DomButton>
<DomButton @click="open = false">Save brief</DomButton>
</template>
<template #companion="{ closeCompanion, mode }">
<section class="flex min-h-0 flex-1 flex-col" :data-mode="mode">
<header class="flex shrink-0 items-start justify-between gap-3 border-b border-border px-4 py-4">
<div class="min-w-0">
<h3 class="text-sm font-semibold text-canvas-fg">Assistant</h3>
<p class="truncate text-xs text-muted-fg">Reading this article brief</p>
</div>
<DomIconButton
label="Close assistant"
size="sm"
variant="secondary"
icon="M6 6l12 12M18 6 6 18"
@click="closeCompanion"
/>
</header>
<div class="min-h-0 flex-1 space-y-4 overflow-y-auto p-4" aria-live="polite">
<div
v-for="message in messages"
:key="message.id"
class="w-fit max-w-[88%] rounded-xl px-3 py-2 text-sm leading-relaxed"
:class="message.role === 'user' ? 'ml-auto bg-primary text-primary-fg' : 'border border-border bg-secondary/40 text-canvas-fg'"
>
{{ message.text }}
</div>
<div class="overflow-hidden rounded-xl border border-border">
<div class="border-b border-border bg-secondary/40 px-3 py-2 text-[11px] font-semibold uppercase tracking-wider text-muted-fg">
Proposed section → article plan
</div>
<p class="px-3 py-3 text-sm leading-relaxed text-canvas-fg">
Add an activation section covering the first meaningful action and one measurable success signal.
</p>
<div class="flex items-center gap-2 px-3 pb-3">
<template v-if="suggestionStatus === 'pending'">
<DomButton size="xs" @click="acceptSuggestion">Accept</DomButton>
<DomButton size="xs" variant="secondary" @click="dismissSuggestion">Dismiss</DomButton>
</template>
<p v-else class="text-xs font-medium" :class="suggestionStatus === 'accepted' ? 'text-success' : 'text-muted-fg'">
{{ suggestionStatus === 'accepted' ? 'Added to the brief' : 'Dismissed' }}
</p>
</div>
</div>
</div>
<footer class="shrink-0 space-y-3 border-t border-border bg-secondary/25 p-3">
<div class="flex flex-wrap gap-2">
<DomButton size="xs" variant="secondary" @click="draft = 'What is missing from this brief?'">What is missing?</DomButton>
<DomButton size="xs" variant="secondary" @click="draft = 'Draft a concise outline'">Draft an outline</DomButton>
</div>
<DomTextareaComposer
v-model="draft"
aria-label="Ask about this brief"
placeholder="Ask about this brief…"
:rows="1"
:max-rows="4"
@submit="sendMessage"
/>
</footer>
</section>
</template>
</DomDialog>
</template>
Companion
Contextual reference panel
The companion is content-agnostic. This settings example uses a narrower reference surface and a different overlay breakpoint without adding another drawer or dialog lifecycle.
npm install @getdom/studio<script setup>
import '@getdom/studio/style.css';
import { ref } from 'vue';
import { DomButton, DomDialog, DomIconButton, DomTextInput, DomToggle } from '@getdom/studio';
const open = ref(false);
const referenceOpen = ref(false);
const projectName = ref('Northstar redesign');
const notifications = ref(true);
</script>
<template>
<DomButton variant="secondary" @click="open = true">Open project settings</DomButton>
<DomDialog
v-model="open"
v-model:companion-open="referenceOpen"
size="lg"
companion-width="19rem"
companion-breakpoint="64rem"
companion-label="Settings reference"
title="Project settings"
description="Change the project identity and notification defaults."
>
<template #header="{ title, description, titleId, descriptionId, companionOpen, companionTriggerProps, toggleCompanion }">
<div class="flex shrink-0 items-start justify-between gap-4">
<div>
<h2 :id="titleId" class="text-lg font-semibold tracking-tight text-canvas-fg">{{ title }}</h2>
<p :id="descriptionId" class="mt-1 text-sm text-muted-fg">{{ description }}</p>
</div>
<DomButton
v-bind="companionTriggerProps"
size="sm"
:variant="companionOpen ? 'primary' : 'secondary'"
@click="toggleCompanion"
>
Reference
</DomButton>
</div>
</template>
<div class="space-y-5">
<DomTextInput v-model="projectName" label="Project name" />
<div class="flex items-center justify-between gap-4 rounded-xl border border-border p-4">
<div>
<p class="text-sm font-medium text-canvas-fg">Weekly summary</p>
<p class="mt-1 text-xs text-muted-fg">Send a concise project activity summary every Friday.</p>
</div>
<DomToggle v-model="notifications" aria-label="Weekly summary" />
</div>
</div>
<template #footer>
<DomButton variant="secondary" @click="open = false">Cancel</DomButton>
<DomButton @click="open = false">Save settings</DomButton>
</template>
<template #companion="{ closeCompanion, mode }">
<section class="flex min-h-0 flex-1 flex-col" :data-mode="mode">
<header class="flex items-start justify-between gap-3 border-b border-border p-4">
<div>
<h3 class="text-sm font-semibold text-canvas-fg">Settings reference</h3>
<p class="mt-1 text-xs text-muted-fg">Context without a second modal lifecycle</p>
</div>
<DomIconButton label="Close reference" size="sm" icon="M6 6l12 12M18 6 6 18" @click="closeCompanion" />
</header>
<div class="min-h-0 flex-1 space-y-5 overflow-y-auto p-4 text-sm leading-relaxed text-muted-fg">
<div>
<p class="font-semibold text-canvas-fg">Project names</p>
<p class="mt-1">Names appear in navigation, notifications and exported reports. Existing links are unaffected.</p>
</div>
<div>
<p class="font-semibold text-canvas-fg">Weekly summaries</p>
<p class="mt-1">Only members with project access receive the summary. Individual notification preferences still apply.</p>
</div>
<div class="rounded-xl border border-border bg-secondary/40 p-3">
<p class="font-medium text-canvas-fg">Responsive check</p>
<p class="mt-1 text-xs">This example switches to the covering mode at 64rem to exercise a different breakpoint.</p>
</div>
</div>
</section>
</template>
</DomDialog>
</template>
Lifecycle
Prepare data before opening
When a dialog renders selected row data or a form draft, avoid clearing that data in the same tick that closes the dialog. Vue will re-render the slot while the leave transition is still visible, which can make the panel shrink or flash empty content.
Use before-open to create the fresh draft or snapshot before the panel appears. Then use close-complete to release that retained data after the close transition has finished.
npm install @getdom/studio<script setup>
import '@getdom/studio/style.css';
import { ref } from 'vue';
import { DomDialog } from '@getdom/studio';
const open = ref(false);
const selectedCustomer = ref(null);
const draft = ref(null);
/**
* Create an editable dialog draft before the panel is shown.
*
* @returns {void}
*/
function prepareDialogData() {
if (!selectedCustomer.value) return;
draft.value = { ...selectedCustomer.value };
}
/**
* Release retained dialog data after the close animation has finished.
*
* @returns {void}
*/
function resetDialogData() {
draft.value = null;
}
<\/script>
<template>
<DomDialog
v-model="open"
title="Edit customer"
@before-open="prepareDialogData"
@close-complete="resetDialogData"
>
<CustomerForm v-if="draft" v-model="draft" />
</DomDialog>
</template>Demo
Programmatic control
Omit the trigger slot — open, close, or toggle via a template ref, or drive state with v-model.
npm install @getdom/studio<script setup>
import '@getdom/studio/style.css';
import { ref } from 'vue';
import { DomDialog, DomButton } from '@getdom/studio';
const dialogRef = ref(null);
</script>
<template>
<div class="flex flex-wrap items-center justify-center gap-2">
<DomButton @click="dialogRef?.open()">Open</DomButton>
<DomButton variant="secondary" @click="dialogRef?.close()">Close</DomButton>
<DomButton variant="ghost" @click="dialogRef?.toggle()">Toggle</DomButton>
</div>
<DomDialog
ref="dialogRef"
title="Programmatic control"
description="No trigger slot — open from script via the component ref."
>
<p class="text-sm text-muted-fg">
Call <code class="text-canvas-fg">open()</code>, <code class="text-canvas-fg">close()</code>, or
<code class="text-canvas-fg">toggle()</code> on the ref, or bind <code class="text-canvas-fg">v-model</code>.
</p>
<template #footer>
<DomButton variant="secondary" data-close>Done</DomButton>
</template>
</DomDialog>
</template>
Demo
Programmatic dialogs
Mount one stack and call useDialogs() from script to await confirmations, component forms, or schema-driven forms.
No dialog result yet.
npm install @getdom/studio<script setup>
import '@getdom/studio/style.css';
import { ref } from 'vue';
import { DomButton, DomDialogStack, useDialogs } from '@getdom/studio';
import InviteDialogForm from './InviteDialogForm.vue';
const {
dialogStack,
confirmDialog,
dialogForm,
resolveDialog,
dismissDialog,
} = useDialogs();
const lastResult = ref('No dialog result yet.');
const schemaFormFields = [
{
component: 'DomTextInput',
props: {
name: 'name',
label: 'Name',
required: true,
placeholder: 'Maya Patel',
},
},
{
component: 'DomEmailInput',
props: {
name: 'email',
label: 'Email',
required: true,
placeholder: 'maya@example.com',
},
},
];
async function confirmArchive() {
const confirmed = await confirmDialog({
title: 'Archive project?',
description: 'The project will move out of the active workspace.',
message: 'You can restore archived projects from workspace settings.',
confirmText: 'Archive',
tone: 'danger',
});
lastResult.value = confirmed ? 'Archive confirmed.' : 'Archive cancelled.';
}
async function openInviteForm() {
const result = await dialogForm(InviteDialogForm, {
defaultEmail: 'maya@example.com',
}, {
title: 'Invite teammate',
description: 'Resolve the dialog with data from a custom form component.',
});
lastResult.value = result?.email ? `Invite queued for ${result.email}.` : 'Invite cancelled.';
}
async function openSchemaForm() {
const result = await dialogForm(schemaFormFields, {
initialValues: {
name: 'Maya Patel',
email: 'maya@example.com',
},
title: 'Schema-defined invite',
description: 'The dialog renders a DomForm from a programmatic children schema.',
confirmText: 'Create invite',
});
lastResult.value = result?.email
? `Schema form resolved for ${result.name} (${result.email}).`
: 'Schema form cancelled.';
}
</script>
<template>
<div class="flex flex-col items-center gap-4">
<div class="flex flex-wrap items-center justify-center gap-2">
<DomButton variant="danger" @click="confirmArchive">Confirm action</DomButton>
<DomButton variant="secondary" @click="openInviteForm">Open form</DomButton>
<DomButton variant="secondary" @click="openSchemaForm">Open schema form</DomButton>
</div>
<p class="text-sm text-muted-fg">{{ lastResult }}</p>
</div>
<DomDialogStack
:dialogs="dialogStack"
@resolve="resolveDialog($event.id, $event.value)"
@dismiss="dismissDialog($event.id, $event.value)"
/>
</template>
Demo
Nested dialogs
Nest a second dialog inside the first — native top-layer stacking means no z-index gymnastics. Esc dismisses the topmost dialog; the one behind stays open.
npm install @getdom/studio<script setup>
import '@getdom/studio/style.css';
import { ref } from 'vue';
import { DomDialog, DomButton, DomTextInput } from '@getdom/studio';
const outer = ref(false);
const confirm = ref(false);
const projectName = ref('');
</script>
<template>
<DomButton variant="danger" @click="outer = true">Delete project…</DomButton>
<DomDialog
v-model="outer"
title="Delete project?"
description="This action cannot be undone."
>
<p class="text-sm text-muted-fg">All issues, comments, and uploads will be removed.</p>
<template #footer>
<DomButton variant="secondary" @click="outer = false">Cancel</DomButton>
<DomButton variant="danger" @click="confirm = true">Continue</DomButton>
</template>
<DomDialog
v-model="confirm"
title="Final confirmation"
description="Type the project name to confirm deletion."
>
<DomTextInput v-model="projectName" label="Project name" placeholder="my-project" />
<template #footer>
<DomButton variant="secondary" @click="confirm = false">Back</DomButton>
<DomButton
variant="danger"
:disabled="projectName !== 'my-project'"
@click="confirm = false; outer = false; projectName = ''"
>
Delete forever
</DomButton>
</template>
</DomDialog>
</DomDialog>
</template>
Demo
Custom dialogs
Replace the title and description block through the header slot while retaining the generated accessibility IDs.
npm install @getdom/studio<script setup>
import '@getdom/studio/style.css';
import { ref } from 'vue';
import { DomDialog, DomButton } from '@getdom/studio';
const open = ref(false);
</script>
<template>
<DomButton variant="danger" @click="open = true">Delete project</DomButton>
<DomDialog
v-model="open"
static
title="Delete project?"
description="This will permanently delete the project and all of its data."
>
<template #header="{ title, description, titleId, descriptionId }">
<div class="flex items-start gap-3 rounded-xl bg-destructive/10 p-4">
<div class="flex size-9 shrink-0 items-center justify-center rounded-full bg-destructive text-destructive-fg" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="size-5">
<path d="M12 9v4m0 4h.01M10.3 3.9 2.6 17.2A2 2 0 0 0 4.3 20h15.4a2 2 0 0 0 1.7-2.8L13.7 3.9a2 2 0 0 0-3.4 0Z" />
</svg>
</div>
<div class="space-y-1">
<h2 :id="titleId" class="font-semibold tracking-tight text-canvas-fg">{{ title }}</h2>
<p :id="descriptionId" class="text-sm text-muted-fg">{{ description }}</p>
</div>
</div>
</template>
<p class="text-sm text-muted-fg">There is no undo. Type the project name to confirm.</p>
<input
class="mt-3 h-10 w-full rounded-full border border-border bg-canvas px-4 text-sm"
placeholder="my-project"
/>
<template #footer>
<DomButton variant="secondary" data-close @click="open = false">Cancel</DomButton>
<DomButton variant="danger" data-close @click="open = false">Delete</DomButton>
</template>
</DomDialog>
</template>
Usage
Plain HTML
Use the headless custom element when you want the same native dialog behaviour in plain HTML or another framework. The headless page includes copyable dialog examples with trigger, close, static, and programmatic controls.
View headless dialogWhy <dialog>
Built on the platform
Top layer
showModal() promotes the dialog above every stacking context — no overflow, transform or z-index can clip it.
Native stacking
Open as many dialogs as you need; the browser stacks them and Esc dismisses the topmost.
Scroll lock
Body scroll is locked automatically while a modal is open.
::backdrop
A pseudo-element you can style with CSS — blur, fade, animate, all native.
Reference
Props
Control props
| Name | Type | TS | Default | Description |
|---|---|---|---|---|
modelValue | boolean | boolean | false | Whether the dialog is open. |
title | string | string | '' | Heading rendered inside the dialog card. |
description | string | string | '' | Supporting copy under the heading. |
size | 'sm' | 'md' | 'lg' | 'xl' | string | 'md' | Width preset for the dialog card. |
width | string | number | string | '' | Optional CSS width. Overrides the size preset when set, for example 95vw. |
height | string | number | string | '' | Optional CSS height. Use values like 95vh for near-full-page dialogs. |
static | boolean | boolean | false | Modal cannot be dismissed by backdrop click or Esc — use footer actions with data-close or v-model. |
backdrop | boolean | boolean | true | Show the dimmed, blurred page backdrop behind the dialog. |
footer | boolean | boolean | true | Show the footer region and default Close action when no footer slot is provided. |
companionOpen | boolean | boolean | false | Whether the optional companion slot is visible. |
companionWidth | string | number | string | '24rem' | Desktop width of the companion surface. |
companionBreakpoint | string | number | string | '56.25rem' | Viewport width at which the companion switches from a side-by-side surface to a covering dialog. |
companionLabel | string | string | 'Companion panel' | Accessible name for the companion surface. |
Auto-generated from Dialog.props and inline _edit hints.
Slots
| Name | Scope | Description |
|---|---|---|
| #trigger | — | Optional. Element that opens the dialog when clicked — omit when using v-model, a template ref, or commandfor on an external button. |
| #(default) | — | Dialog body — rendered inside the card below the title and description. |
| #header | { title, description, titleId, descriptionId, companionOpen, companionMode, companionTriggerProps, toggleCompanion } | Replace the default title and description block. Apply the provided IDs to custom heading and description elements to preserve the dialog accessibility relationships. Companion helpers wire an in-dialog toggle when the companion slot is present. |
| #footer | — | Action buttons. Add `data-close` on a control to dismiss the dialog. |
| #companion | { open, mode, closeCompanion, toggleCompanion } | Optional contextual surface shown beside the dialog on wide screens and over it below companionBreakpoint. |
Events
| Name | Payload | Description |
|---|---|---|
| @update:modelValue | ( | Emitted when the dialog opens or closes. |
| @update:companionOpen | ( | Emitted when the companion surface opens or closes. |
| @before-open | — | Emitted immediately before the dialog opens; prepare draft data or item snapshots here. |
| @close | — | Emitted when the dialog is dismissed. |
| @close-complete | — | Emitted after the close transition completes; clear retained dialog data here. |
Names auto-detected from defineEmits and source emit() calls; payload and description from __doc.events when present.
Keyboard
- EscCloses an open companion first, then closes the dialog unless `static` is set.
- Tab / Shift+TabFocus stays within the dialog while open (native top-layer).
- Click backdropDismisses the dialog unless `static` is set.
Related components
Generated from sibling component files that share this documentation route.
Dialog schema formInternal renderer used by dialogForm(schema) to resolve a DomForm children schema from a programmatic dialog.⌄
<DomDialogSchemaForm>Props
Control props
| Name | Type | TS | Default | Description |
|---|---|---|---|---|
schema | array | Array<unknown> | [] | — |
modelValue | object | Record<string, unknown> | {} | — |
submitText | string | string | 'Submit' | — |
cancelText | string | string | 'Cancel' | — |
invalidMessage | string | string | 'Complete the required fields before continuing.' | — |
Auto-generated from Dialog schema form.props and inline _edit hints.
Events
| Name | Payload | Description |
|---|---|---|
| @resolve | Record<string, unknown> | Emitted with validated form values. |
| @dismiss | — | Emitted when the form is cancelled. |
Names auto-detected from defineEmits and source emit() calls; payload and description from __doc.events when present.
Dialog stackRenderer for dialogs created with useDialogs(). Mount it once, then call the dialog API from script.⌄
<DomDialogStack>Props
Control props
| Name | Type | TS | Default | Description |
|---|---|---|---|---|
dialogsts | array | Array< | [] | Visible programmatic dialogs from useDialogs(). |
Auto-generated from Dialog stack.props and inline _edit hints.
Slots
| Name | Scope | Description |
|---|---|---|
| #default | { dialog, resolve, dismiss } | Custom renderer for a programmatic dialog body. |
| #footer | { dialog, resolve, dismiss } | Custom renderer for programmatic dialog actions. |
Events
| Name | Payload | Description |
|---|---|---|
| @resolve | ({ id, value }) | Emitted when a dialog resolves. |
| @dismiss | ({ id, value }) | Emitted when a dialog is dismissed. |
| @action | ({ id, dialog, ...payload }) | Emitted by custom dialog bodies for app-specific actions. |
Names auto-detected from defineEmits and source emit() calls; payload and description from __doc.events when present.