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.

Dialog title

Supporting text shown under the heading.

Dialog body content.

Playground.vuevue
Install
npm install @getdom/studio
vue
<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.

Delete project?

This will permanently delete the project and all of its data.

There is no undo. Type the project name to confirm.

Delete.vuevue
Install
npm install @getdom/studio
vue
<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.

Terms and conditions

Read the full agreement before accepting.

Account access

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.

Acceptable use

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.

Customer content

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.

Service changes

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.

Billing

Paid plans renew automatically unless cancelled before the renewal date. Taxes, usage charges, and subscription changes may be reflected on future invoices.

Privacy

Personal data is handled according to the privacy notice. Administrative users should make sure their teams understand what information is submitted to the service.

Availability

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.

Termination

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.

By accepting, you confirm that you have reviewed the terms above and are authorised to agree on behalf of your workspace.

Scroll to the end to continue.

Install
npm install @getdom/studio
vue
<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.

Edit post

Update editorial content, publishing state, and search metadata in a near-full-page dialog.

Publishing workflow

Draft content and review settings stay together so editors can make a final pass quickly.

Scheduled
Feature this postHighlight it in the editorial index.
Install
npm install @getdom/studio
vue
<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.

DraftArticle brief

Product onboarding

Review the brief and ask the assistant to suggest focused changes.

Install
npm install @getdom/studio
vue
<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.

Project settings

Change the project identity and notification defaults.

Weekly summary

Send a concise project activity summary every Friday.

Install
npm install @getdom/studio
vue
<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.

DialogLifecycle.vuevue
Install
npm install @getdom/studio
vue
<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.

Programmatic control

No trigger slot — open from script via the component ref.

Call open(), close(), or toggle() on the ref, or bind v-model.

Install
npm install @getdom/studio
vue
<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.

Install
npm install @getdom/studio
vue
<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.

Delete project?

This action cannot be undone.

All issues, comments, and uploads will be removed.

Final confirmation

Type the project name to confirm deletion.

Nested.vuevue
Install
npm install @getdom/studio
vue
<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.

Delete project?

This will permanently delete the project and all of its data.

There is no undo. Type the project name to confirm.

Custom.vuevue
Install
npm install @getdom/studio
vue
<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 dialog

Why <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

NameTypeTSDefaultDescription
modelValuebooleanbooleanfalseWhether the dialog is open.
titlestringstring''Heading rendered inside the dialog card.
descriptionstringstring''Supporting copy under the heading.
size'sm' | 'md' | 'lg' | 'xl'string'md'Width preset for the dialog card.
widthstring | numberstring''Optional CSS width. Overrides the size preset when set, for example 95vw.
heightstring | numberstring''Optional CSS height. Use values like 95vh for near-full-page dialogs.
staticbooleanbooleanfalseModal cannot be dismissed by backdrop click or Esc — use footer actions with data-close or v-model.
backdropbooleanbooleantrueShow the dimmed, blurred page backdrop behind the dialog.
footerbooleanbooleantrueShow the footer region and default Close action when no footer slot is provided.
companionOpenbooleanbooleanfalseWhether the optional companion slot is visible.
companionWidthstring | numberstring'24rem'Desktop width of the companion surface.
companionBreakpointstring | numberstring'56.25rem'Viewport width at which the companion switches from a side-by-side surface to a covering dialog.
companionLabelstringstring'Companion panel'Accessible name for the companion surface.

Auto-generated from Dialog.props and inline _edit hints.

Slots

NameScopeDescription
#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

NamePayloadDescription
@update:modelValue(open
open: boolean;
: boolean)
Emitted when the dialog opens or closes.
@update:companionOpen(open
open: boolean;
: boolean)
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

NameTypeTSDefaultDescription
schemaarrayArray<unknown>[]—
modelValueobjectRecord<string, unknown>{}—
submitTextstringstring'Submit'—
cancelTextstringstring'Cancel'—
invalidMessagestringstring'Complete the required fields before continuing.'—

Auto-generated from Dialog schema form.props and inline _edit hints.

Events

NamePayloadDescription
@resolveRecord<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

NameTypeTSDefaultDescription
dialogs
ts
[
	{
		id: "dialog-1",
		title: "Delete project?",
		description: "This action cannot be undone.",
		message: "Are you sure?",
		type: "dialog",
	}
]
arrayArray<DialogsItem
type DialogsItem = {
	id?: string; // ID
	title?: string; // Title
	description?: string; // Description
	message?: string; // Message
	type?: string; // Type
};
>
[]Visible programmatic dialogs from useDialogs().

Auto-generated from Dialog stack.props and inline _edit hints.

Slots

NameScopeDescription
#default{ dialog, resolve, dismiss }Custom renderer for a programmatic dialog body.
#footer{ dialog, resolve, dismiss }Custom renderer for programmatic dialog actions.

Events

NamePayloadDescription
@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.