Dialog
A focus-trapping overlay.
Anatomy
<script>
import { Dialog } from '@shardsui/svelte/dialog'
</script>
<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>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 — no props required.
<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>Drive open with open / onOpenChange, or bind:open.
<script>
let open = $state(false)
</script>
<Dialog.Root {open} onOpenChange={(next) => (open = next)}>
<Dialog.Trigger>Open</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Popup>
<form
onsubmit={async () => {
// Close the dialog once the form data is submitted
await submitData()
open = false
}}
>
...
</form>
</Dialog.Popup>
</Dialog.Portal>
</Dialog.Root>onOpenChange is also the place to run side effects when the dialog opens or closes — prefer it over $effect.
<Dialog.Root
{open}
onOpenChange={(next) => {
// Do stuff when the dialog is closed
if (!next) {
doStuff()
}
// Set the new state
open = next
}}
>
...
</Dialog.Root>Open from a menu
To open a dialog from a menu, keep the dialog controlled and flip its state from the menu item's onclick handler.
<script>
import { Dialog } from '@shardsui/svelte/dialog'
import { Menu } from '@shardsui/svelte/menu'
let dialogOpen = $state(false)
</script>
<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 onclick={() => (dialogOpen = true)}>Open dialog</Menu.Item>
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>
<!-- Control the dialog state -->
<Dialog.Root open={dialogOpen} onOpenChange={(v) => (dialogOpen = v)}>
<Dialog.Portal>
<Dialog.Backdrop />
<Dialog.Popup>
<!-- Rest of the dialog -->
</Dialog.Popup>
</Dialog.Portal>
</Dialog.Root>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 with a function binding — bind:open={() => open, (next) => …}. When a close is requested the setter runs; if you don't commit the new value, the getter keeps returning 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.
<Dialog.Root
bind:open={
() => open,
(next) => {
if (!next && hasUnsavedChanges) return // veto: don't commit, dialog stays open
open = next
}
}
>Custom focus management
Control where focus goes when the dialog opens and closes with the initialFocus and finalFocus props on <Dialog.Popup>.
Set either to false to leave focus where it is, or to a function that returns the element to focus based on the interaction type.
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
For a simple one-off, 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 new Dialog.Handle() — no shared open state needed.
<script>
const myDialog = new Dialog.Handle()
</script>
<Dialog.Trigger handle={myDialog}>Open</Dialog.Trigger>
<Dialog.Root handle={myDialog}>...</Dialog.Root>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>.
<Dialog.Root>
<Dialog.Trigger>Trigger 1</Dialog.Trigger>
<Dialog.Trigger>Trigger 2</Dialog.Trigger>
...
</Dialog.Root><script>
const demoDialog = new Dialog.Handle()
</script>
<Dialog.Trigger handle={demoDialog}>Trigger 1</Dialog.Trigger>
<Dialog.Trigger handle={demoDialog}>Trigger 2</Dialog.Trigger>
<Dialog.Root handle={demoDialog}>...</Dialog.Root>To show different content depending on which trigger opened the dialog, pass a payload to each <Dialog.Trigger> and read it through the children snippet on <Dialog.Root>. Give new Dialog.Handle() a type argument to type the payload:
<script>
const demoDialog = new Dialog.Handle<{ text: string }>()
</script>
<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 handle={demoDialog}>
{#snippet children({ payload })}
<Dialog.Portal>
<Dialog.Popup>
<Dialog.Title>Dialog</Dialog.Title>
{#if payload !== undefined}
<Dialog.Description>This has been opened by {payload.text}</Dialog.Description>
{/if}
</Dialog.Popup>
</Dialog.Portal>
{/snippet}
</Dialog.Root>Controlled mode with multiple triggers
When the dialog's visibility depends on your app's state, drive it with the open and onOpenChange props on <Dialog.Root>. With multiple triggers, track the active one with bind:triggerId on <Dialog.Root> and the id prop on each <Dialog.Trigger>: give each trigger an id, and the dialog writes back the one 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 = new Dialog.Handle<Payload>()