Dialog
A modal window over the page that traps focus until it closes.
Basic
Uncontrolled: the trigger opens it; Escape, a click outside, the X or Cancel close it.
Code
<Dialog title="Edit profile" description="Make changes to your profile. Save when you're done.">
<Button slot="trigger">Edit profile</Button>
<div class="field">
<label for="name">Name</label>
<input id="name" value={form.name}>
</div>
<DialogFooter>
<DialogClose><Button>Cancel</Button></DialogClose>
<Button variant="primary" onclick={() => save(form)}>Save changes</Button>
</DialogFooter>
</Dialog>Usage
import { Dialog, DialogFooter, DialogClose } from "@elements/ui/dialog";
<Dialog title="Edit profile" description="Make changes, then save.">
<Button slot="trigger">Edit profile</Button>
<input value={form.name}>
<DialogFooter>
<DialogClose><Button>Cancel</Button></DialogClose>
<Button variant="primary" onclick={() => save(form)}>Save</Button>
</DialogFooter>
</Dialog>
<!-- controlled: open is two-way -->
<Dialog open={state.editing} title="Edit profile">...</Dialog>Examples
Controlled
Bind open to state. The dialog writes false back when it closes itself; onOpenChange reports its own requests.
Code
<Button onclick={() => state.open = true}>Open from anywhere</Button>
<p>open: {state.open}</p>
<Dialog open={state.open} title="Controlled"
onOpenChange={(open) => log(open)}>
...
</Dialog>Requires a choice
No close button, and neither Escape nor a click outside closes it.
Code
<Dialog title="Accept the terms" closeButton={false}
dismissOnEscape={false} dismissOnOutside={false}>
<Button slot="trigger">Review terms</Button>
...
<DialogFooter>
<DialogClose><Button variant="primary">I accept</Button></DialogClose>
</DialogFooter>
</Dialog>Escape guard
onEscapeKeyDown can call preventDefault() to keep the dialog open.
Code
<Dialog title="Draft" onEscapeKeyDown={(e) => state.locked && e.preventDefault()}>
<label><input type="checkbox" checked={state.locked}> Keep open on Escape</label>
</Dialog>Escape reaches the page
onEscapeKeyDown can call allowPropagation(): Escape closes the dialog and still reaches the page's own key handlers.
Code
<Dialog title="Shortcuts" onEscapeKeyDown={(e) => e.allowPropagation()}>
<Button slot="trigger">Shortcuts</Button>
<p>Escape closes this dialog and still reaches the page's own key handler.</p>
</Dialog>Focus by interaction type
initialFocus and finalFocus can be functions of how the dialog opened or closed: keyboard, mouse, touch or pen.
Code
<Dialog title="Find a file"
initialFocus={(type) => type === "keyboard" ? "[data-search]" : true}
finalFocus={(type) => type === "keyboard" ? true : "[data-after]"}>
<Button slot="trigger">Find a file</Button>
<input aria-label="Folder" value="Documents">
<input aria-label="Search" data-search>
</Dialog>Initial focus
initialFocus names the element to focus on open; without it, the first field gets focus.
Code
<Dialog title="Rename file" initialFocus="[data-initial]">
<input value="report.pdf">
<input value="final-report.pdf" data-initial>
</Dialog>One dialog in place of another
Add closes the address dialog and opens a confirmation in its place. OK returns focus to the button that opened the first dialog.
Code
<Button onclick={() => state.address = true}>Add delivery address</Button>
<Dialog open={state.address} title="Add delivery address">
...fields...
<DialogFooter>
<DialogClose><Button>Cancel</Button></DialogClose>
<Button variant="primary" onclick={() => { state.address = false; state.added = true; }}>Add</Button>
</DialogFooter>
</Dialog>
<Dialog open={state.added} title="Address added" initialFocus="[data-ok]" closeButton={false}>
<DialogFooter>
<DialogClose><Button variant="primary" attrs={{ "data-ok": true }}>OK</Button></DialogClose>
</DialogFooter>
</Dialog>Nested
A dialog opened from a dialog. Escape closes the top one; focus returns to the button that opened it.
Code
<Dialog title="Settings">
<Button slot="trigger">Settings</Button>
<Dialog title="Delete account?" size="sm">
<Button slot="trigger" variant="danger">Delete account</Button>
...
</Dialog>
</Dialog>Title and description parts
DialogTitle and DialogDescription take rich content and are wired to aria-labelledby and aria-describedby.
Code
<Dialog>
<Button slot="trigger">Release notes</Button>
<DialogHeader>
<DialogTitle>Version <em>2.4</em></DialogTitle>
<DialogDescription>What changed this week.</DialogDescription>
</DialogHeader>
...
</Dialog>Non-modal
modal={false}: no backdrop, no focus trap, the page stays usable; a click or focus outside closes it.
Code
<Dialog title="Inspector" modal={false}>
<Button slot="trigger">Open inspector</Button>
...
</Dialog>Trap focus only
modal="trap-focus": Tab stays inside, but the page scrolls and takes clicks. onInteractOutside can keep it open.
Code
<Dialog title="Filters" modal="trap-focus"
onInteractOutside={(e) => state.pinned && e.preventDefault()}>
<Button slot="trigger">Open filters</Button>
...
</Dialog>Focus on close and focus events
finalFocus names where focus goes on close. onOpenAutoFocus and onCloseAutoFocus can take over focus with preventDefault().
Code
<Dialog title="Add rows" finalFocus="[data-new-row]">
<Button slot="trigger">Add rows</Button>
...
</Dialog>
<Dialog title="Focus events"
onOpenAutoFocus={(e) => { e.preventDefault(); save.focus(); }}
onCloseAutoFocus={(e) => e.preventDefault()}
onOpenChange={(open, reason) => log(reason)}
onOpenChangeComplete={(open) => log(open)}>
...
</Dialog>Close after an async submit
A controlled dialog stays open while the form saves, then the page closes it.
Code
<Dialog open={state.renaming} title="Rename project">
<form onsubmit={(e) => save(state, e)}>
<input value={state.name}>
<Button type="submit" variant="primary" loading={state.saving}>Save</Button>
</form>
</Dialog>
async function save(state, e) {
e.preventDefault();
state.saving = true;
await rename(state.name);
state.saving = false;
state.renaming = false;
}Several triggers
Any number of buttons, anywhere on the page, open one controlled dialog with their own content. triggers names them: the one that opened it is marked open, and focus returns to it.
Code
<Button attrs={{ "data-person": "" }} onclick={() => open(state, "Ada Lovelace")}>Ada Lovelace</Button>
<Button attrs={{ "data-person": "" }} onclick={() => open(state, "Grace Hopper")}>Grace Hopper</Button>
<Dialog open={state.profile} title={state.person} triggers="[data-person]">
<p>Viewing {state.person}.</p>
</Dialog>Open from a menu
A menu item opens a controlled dialog. On close, focus goes back to the menu's button, since the item is gone.
Code
<DropdownMenu>
<Button slot="trigger">Actions</Button>
<DropdownMenuItem onSelect={() => state.renaming = true}>Rename...</DropdownMenuItem>
</DropdownMenu>
<Dialog open={state.renaming} title="Rename">...</Dialog>Long content, page scroll
By default a tall dialog scrolls with the overlay, like a page. Focus starts on the text at the top, so it opens at its start.
Code
<Dialog title="Terms of service" initialFocus="[data-start]">
<Button slot="trigger">Read terms</Button>
<p tabindex="-1" data-start>These terms cover your use of the service.</p>
...many paragraphs...
</Dialog>Scrollable body, sticky footer
scroll="inside": the panel fits the screen, the header and footer stay put and DialogBody scrolls.
Code
<Dialog title="Terms of service" scroll="inside">
<Button slot="trigger">Read terms</Button>
<DialogBody>...many paragraphs...</DialogBody>
<DialogFooter>...</DialogFooter>
</Dialog>Elements outside the panel
Content can sit outside the visible panel and stay in the tab order. With no title, label names the dialog.
Code
<Dialog label="Photo" closeButton={false}>
<Button slot="trigger">View photo</Button>
<DialogClose>
<Button icon round label="Close photo" class="outside-close"><Icon name="x" /></Button>
</DialogClose>
<img src="./photo.jpg" alt="">
</Dialog>Right to left
Under dir=rtl the header, close button and footer mirror.
Code
<div dir="rtl">
<Dialog title="تعديل الملف" closeLabel="إغلاق">
<Button slot="trigger">فتح الحوار</Button>
...
</Dialog>
</div>Pointer outside
onPointerDownOutside sees each press outside; preventDefault keeps it open. returnFocus={false} leaves focus where it is on close.
Code
<Dialog title="Pinned note" returnFocus={false}
onPointerDownOutside={(e) => e.preventDefault()}>
<Button slot="trigger">Open pinned note</Button>
<input aria-label="Note">
</Dialog>Open on load
open={true} without a binding: the dialog is open when the page loads, then owns its state. Escape closes it and the trigger opens it again.
Code
<Dialog open={true} title="What's new">
<Button slot="trigger">What's new</Button>
<p>Open when the page loads. The dialog owns open from here.</p>
</Dialog>Non-modal, stays open
modal={false} with dismissOnOutside={false}: neither a click nor focus outside closes it.
Code
<Dialog title="Inspector" modal={false} dismissOnOutside={false}>
<Button slot="trigger">Open pinned inspector</Button>
<input aria-label="Height" value="240">
</Dialog>Full screen
size="full": a wide panel on a desktop, and edge to edge on a phone.
Code
<Dialog title="Compose" size="full">
<Button slot="trigger">Compose</Button>
...
</Dialog>Focus fallback
A focus function that returns null falls back to the default: the first field on open, the trigger on close.
Code
<Dialog title="Move file"
initialFocus={() => document.querySelector<HTMLElement>("[data-recent]")}
finalFocus={() => document.querySelector<HTMLElement>("[data-recent]")}>
<Button slot="trigger">Move file</Button>
<input aria-label="Folder">
</Dialog>Footer close button
DialogFooter closeButton appends an outline Close button that closes the dialog.
Code
<Dialog title="Share link" description="Anyone with the link can view this file." closeButton={false}>
<Button slot="trigger">Share</Button>
<input aria-label="Link" value="https://example.com/f/42" readonly>
<DialogFooter closeButton/>
</Dialog>Open without moving focus
initialFocus={() => false}: the dialog opens and focus stays where it was, here on the trigger, which closes it again.
Code
<Dialog title="Find" modal={false} dismissOnOutside={false} initialFocus={() => false}>
<Button slot="trigger">Find in page</Button>
<input aria-label="Find">
</Dialog>