Dialog
A focus-trapping overlay.
Anatomy
<script setup lang="ts">
import { Dialog } from '@shardsui/vue/dialog'
</script>
<template>
<Dialog.Root>
<Dialog.Trigger />
<Dialog.Portal>
<Dialog.Backdrop />
<Dialog.Viewport>
<Dialog.Popup>
<Dialog.Title />
<Dialog.Description />
<Dialog.Close />
</Dialog.Popup>
</Dialog.Viewport>
</Dialog.Portal>
</Dialog.Root>
</template>Dialog.Viewport is optional. It provides a scrollable positioning container for the popup. When not needed, use Dialog.Popup directly with fixed positioning.
Usage guidelines
- Dialog doesn't support gestures: if you need gestures or snap points, use Drawer. A panel that slides in from the screen edge without gestures is just a positioned Dialog.
Examples
State
By default, Dialog manages its own open state, and no props are required.
<template>
<Dialog.Root>
<Dialog.Trigger>Open</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Popup>
<Dialog.Title>Example dialog</Dialog.Title>
<Dialog.Close>Close</Dialog.Close>
</Dialog.Popup>
</Dialog.Portal>
</Dialog.Root>
</template>Drive open with :open / @update:open, or v-model:open.
<script setup>
import { shallowRef } from 'vue'
const open = shallowRef(false)
</script>
<template>
<Dialog.Root :open="open" @update:open="(next) => (open = next)">
<Dialog.Trigger>Open</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Popup>
<form
@submit.prevent="
async () => {
// Close the dialog once the form data is submitted
await submitData()
open = false
}
"
>
...
</form>
</Dialog.Popup>
</Dialog.Portal>
</Dialog.Root>
</template>@update:open is also the place to run side effects when the dialog opens or closes. Prefer it over a watcher.
<template>
<Dialog.Root
:open="open"
@update:open="
(next) => {
// Do stuff when the dialog is closed
if (!next) {
doStuff()
}
// Set the new state
open = next
}
"
>
...
</Dialog.Root>
</template>Open from a menu
To open a dialog from a menu, keep the dialog controlled and flip its state from the menu item's @click handler.
<script setup>
import { Dialog } from '@shardsui/vue/dialog'
import { Menu } from '@shardsui/vue/menu'
import { shallowRef } from 'vue'
const dialogOpen = shallowRef(false)
</script>
<template>
<Menu.Root>
<Menu.Trigger>Open menu</Menu.Trigger>
<Menu.Portal>
<Menu.Positioner>
<Menu.Popup>
<!-- Open the dialog when the menu item is clicked -->
<Menu.Item @click="dialogOpen = true">Open dialog</Menu.Item>
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>
<!-- Control the dialog state -->
<Dialog.Root v-model:open="dialogOpen">
<Dialog.Portal>
<Dialog.Backdrop />
<Dialog.Popup>
<!-- Rest of the dialog -->
</Dialog.Popup>
</Dialog.Portal>
</Dialog.Root>
</template>Nested dialogs
Dialogs can be nested. Style the parent through the [data-nested-dialog-open] selector and the var(--nested-dialogs) CSS variable. Child dialogs render their own backdrop, marked with data-nested. Hide it with [data-nested] { opacity: 0 } to keep the parent visible behind the one on top.
Close confirmation
A nested confirmation dialog guards against losing work: it opens when the text typed into the parent dialog is about to be discarded.
Veto the close by controlling open and deciding in the @update:open handler. When a close is requested the handler runs; if you don't commit the new value, the prop keeps the old one and the dialog stays open. Open the confirmation there instead, so the prompt appears whether the user clicks the backdrop, presses Esc, or hits a close button.
<template>
<Dialog.Root
:open="open"
@update:open="
(next) => {
if (!next && hasUnsavedChanges) return // veto: don't commit, dialog stays open
open = next
}
"
>
...
</Dialog.Root>
</template>Custom focus management
Control where focus goes when the dialog opens and closes with the initialFocus and finalFocus props on <Dialog.Popup>.
Outside scroll dialog
For long content, make <Dialog.Viewport> the outer scrollable container and let <Dialog.Popup> extend past the bottom edge. The scrollable area draws custom scrollbars with the Scroll Area component.
Inside scroll dialog
Here the popup stays fully on screen and an inner container scrolls instead. <Dialog.Viewport> positions <Dialog.Popup>, and the inner scrollable area is built with the Scroll Area component.
Placing elements outside the popup
To place elements "outside" the colored popup area, still render them inside <Dialog.Popup> and move the popup styles onto a child element. This preserves tab order and correct screen-reader announcements.
<Dialog.Popup> uses pointer-events: none while its inner content — the colored popup and close button — uses pointer-events: auto, so backdrop clicks still register.
Detached triggers
Keep <Dialog.Trigger> inside the root, as in the example at the top of this page. When the trigger and the dialog's content can't sit together in the markup, detach them: connect the trigger to a <Dialog.Root> with a shared handle from Dialog.createHandle(), with no shared open state needed.
<!-- [!code word::handle="myDialog"] -->
<script setup>
const myDialog = Dialog.createHandle()
</script>
<template>
<Dialog.Trigger :handle="myDialog">Open</Dialog.Trigger>
<Dialog.Root :handle="myDialog">...</Dialog.Root>
</template>Multiple triggers
Several triggers can open the same dialog. Share one handle across detached triggers, or drop multiple <Dialog.Trigger> components inside a single <Dialog.Root>.
<template>
<Dialog.Root>
<Dialog.Trigger>Trigger 1</Dialog.Trigger>
<Dialog.Trigger>Trigger 2</Dialog.Trigger>
...
</Dialog.Root>
</template><script setup>
const demoDialog = Dialog.createHandle()
</script>
<template>
<Dialog.Trigger :handle="demoDialog">Trigger 1</Dialog.Trigger>
<Dialog.Trigger :handle="demoDialog">Trigger 2</Dialog.Trigger>
<Dialog.Root :handle="demoDialog">...</Dialog.Root>
</template>To show different content depending on which trigger opened the dialog, pass a payload to each <Dialog.Trigger> and read it through the default slot on <Dialog.Root>. Give Dialog.createHandle() a type argument to type the payload:
<script setup lang="ts">
const demoDialog = Dialog.createHandle<{ text: string }>()
</script>
<template>
<Dialog.Trigger :handle="demoDialog" :payload="{ text: 'Trigger 1' }">Trigger 1</Dialog.Trigger>
<Dialog.Trigger :handle="demoDialog" :payload="{ text: 'Trigger 2' }">Trigger 2</Dialog.Trigger>
<Dialog.Root v-slot="{ payload }" :handle="demoDialog">
<Dialog.Portal>
<Dialog.Popup>
<Dialog.Title>Dialog</Dialog.Title>
<Dialog.Description v-if="payload !== undefined">
This has been opened by {{ payload.text }}
</Dialog.Description>
</Dialog.Popup>
</Dialog.Portal>
</Dialog.Root>
</template>Controlled mode with multiple triggers
With multiple triggers, track the active one with v-model:trigger-id on <Dialog.Root> and the id prop on each <Dialog.Trigger>. The dialog writes back the id of the trigger that opened it.
API reference
Root
Groups all parts of the dialog. Doesn't render its own HTML element.
Trigger
A button that opens the dialog.
Renders a <button> element.
Portal
A portal that moves the popup out to <body>, clear of ancestor clipping and stacking.
Renders a <div> element.
Backdrop
An overlay displayed beneath the popup.
Renders a <div> element.
Viewport
A positioning container for the dialog popup that can be made scrollable.
Renders a <div> element.
Popup
A container for the dialog contents.
Renders a <div> element.
Title
A heading that labels the dialog.
Renders an <h2> element.
Description
A paragraph with additional information about the dialog.
Renders a <p> element.
Close
A button that closes the dialog.
Renders a <button> element.
Handle
Connects a <Dialog.Root> with detached <Dialog.Trigger> components, and controls the dialog imperatively. Pass a type argument to type the payload.
const dialog = Dialog.createHandle<Payload>()