Modal window for focused interactions and confirmations
Tags: component overlay modal
Dialogs are modal windows that overlay the page content, focusing user attention on a specific task or message.
Import
<script>
import { Dialog } from '@classic-homes/theme-svelte';
</script>Basic Usage
<script>
let open = $state(false);
</script>
<Button onclick={() => (open = true)}>Open Dialog</Button>
<Dialog bind:open title="Dialog Title" description="Dialog description text">
<p>Dialog content goes here.</p>
</Dialog>Props
| Prop | Type | Default | Description |
|---|---|---|---|
open |
boolean |
false |
Whether the dialog is open (bindable) |
title |
string |
- | Dialog title displayed in header |
description |
string |
- | Description text below title |
showClose |
boolean |
true |
Whether to show the close button |
class |
string |
- | Additional CSS classes for content |
onOpenChange |
(open: boolean) => void |
- | Callback when open state changes |
size |
'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'full' |
'md' |
Dialog size - controls max-width (default: 'md') |
trigger |
Snippet<[{ props }]> |
- | Optional trigger; spread props on your own button |
footer |
Snippet |
- | Optional footer element |
header |
Snippet |
- | Optional custom header content (replaces title/description) |
headerActions |
Snippet |
- | Optional header actions (rendered before the close button) |
onCloseAutoFocus |
(event: Event) => void |
- | Called before focus returns to the trigger on close; preventDefault() keeps focus where the caller put it (e.g. a field it just revealed). |
dirty |
boolean |
- | Unsaved work inside; overlay click, Escape and the close button ask before discarding it. Unset = auto-detected from edits; false turns the prompt off |
Snippets
| Snippet | Description |
|---|---|
children |
Main dialog content |
trigger |
Element that opens the dialog |
footer |
Footer section for actions |
headerActions |
Actions before close button in header |
With Trigger
Use the trigger snippet for inline trigger buttons. It receives { props }: spread them on your own button so it becomes the trigger, carries aria-expanded, and gets focus back when the dialog closes. A snippet that ignores them still works, wrapped in a span as before.
<Dialog title="Confirm Action">
{#snippet trigger({ props })}
<Button {...props}>Open Dialog</Button>
{/snippet}
<p>Are you sure you want to continue?</p>
{#snippet footer()}
<div class="flex gap-2 justify-end">
<Button variant="outline">Cancel</Button>
<Button>Confirm</Button>
</div>
{/snippet}
</Dialog>With Footer
Add action buttons in the footer:
<Dialog bind:open title="Save Changes?">
<p>You have unsaved changes. Would you like to save them?</p>
{#snippet footer()}
<Button variant="outline" onclick={() => (open = false)}>Don't Save</Button>
<Button onclick={handleSave}>Save</Button>
{/snippet}
</Dialog>Header Actions
Add custom actions to the header:
<Dialog bind:open title="Document">
{#snippet headerActions()}
<Button variant="ghost" size="icon" onclick={handleDownload}>
<DownloadIcon class="h-4 w-4" />
</Button>
{/snippet}
<p>Document content here...</p>
</Dialog>Without Close Button
Hide the close button for required interactions:
<Dialog bind:open title="Terms of Service" showClose={false}>
<p>You must accept the terms to continue.</p>
{#snippet footer()}
<Button
onclick={() => {
acceptTerms();
open = false;
}}
>
Accept
</Button>
{/snippet}
</Dialog>Unsaved Changes
Once someone edits a field inside the dialog, an overlay click, Escape or the close button no longer closes it straight away: an inline "Discard unsaved changes?" prompt replaces the footer, with Keep editing (focused) and Discard. Escape on the prompt goes back to editing. Closing in code (open = false, your own Cancel or Save button) is never blocked.
Edits are detected from input/change events and checkbox, switch or radio clicks inside the dialog, and forgotten each time it opens. Search inputs (type="search") and anything under data-dirty-ignore don't count, so pickers stay one-click. Pass dirty when you know better, e.g. rows that save as you go:
<Dialog bind:open title="Incentives" dirty={drafts.some(isUnsaved)}>
<!-- rows with their own Save buttons -->
</Dialog>
<!-- A dialog with its own discard flow -->
<Dialog bind:open title="Upload" dirty={false}>...</Dialog>Controlled Dialog
Control the dialog programmatically. onOpenChange reports a close after it happens; use dirty (above) to stop one.
<script>
let open = $state(false);
</script>
<Dialog {open} onOpenChange={(next) => (open = next)} title="Edit">
<!-- content -->
</Dialog>Animation
The dialog includes smooth animations:
- Fade in/out overlay
- Zoom and slide content animation
- 200ms duration
Accessibility
The Dialog component includes:
- Focus trapping within dialog
- Escape key to close
- Click outside to close
- Proper ARIA attributes
- Screen reader announcements
- Focus returns to trigger on close
:::note Keyboard Navigation
Escapecloses the dialogTabcycles through focusable elements- Focus is trapped within the dialog :::
Examples
Confirmation Dialog
<script>
let deleteDialogOpen = $state(false);
async function handleDelete() {
await deleteItem();
deleteDialogOpen = false;
}
</script>
<Button variant="destructive" onclick={() => (deleteDialogOpen = true)}>Delete</Button>
<Dialog
bind:open={deleteDialogOpen}
title="Delete Item"
description="This action cannot be undone."
>
<p>Are you sure you want to delete this item permanently?</p>
{#snippet footer()}
<Button variant="outline" onclick={() => (deleteDialogOpen = false)}>Cancel</Button>
<Button variant="destructive" onclick={handleDelete}>Delete</Button>
{/snippet}
</Dialog>Form Dialog
<Dialog bind:open title="Edit Profile">
<form onsubmit|preventDefault={handleSubmit} class="space-y-4">
<FormField label="Name" id="name" bind:value={name} required />
<FormField label="Email" id="email" type="email" bind:value={email} required />
</form>
{#snippet footer()}
<Button variant="outline" onclick={() => (open = false)}>Cancel</Button>
<Button onclick={handleSubmit}>Save Changes</Button>
{/snippet}
</Dialog>Info Dialog
<Dialog title="Keyboard Shortcuts">
<dl class="space-y-2">
<div class="flex justify-between">
<dt class="font-medium">Save</dt>
<dd><kbd class="px-2 py-1 bg-muted rounded text-sm">Ctrl+S</kbd></dd>
</div>
<div class="flex justify-between">
<dt class="font-medium">Search</dt>
<dd><kbd class="px-2 py-1 bg-muted rounded text-sm">Ctrl+K</kbd></dd>
</div>
</dl>
{#snippet trigger({ props })}
<Button {...props} variant="ghost" size="icon">
<HelpIcon class="h-4 w-4" />
</Button>
{/snippet}
</Dialog>Wide Dialog
<Dialog bind:open title="Preview" class="max-w-4xl">
<img src={previewUrl} alt="Preview" class="w-full" />
</Dialog>Related Components
- AlertDialog - For destructive action confirmations
- Tooltip - For brief hover information
- DropdownMenu - For action menus