Skip to main content

Dialog

Dialog

Modal window for focused interactions and confirmations

Tags: component overlay modal

View Demo

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>

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

  • Escape closes the dialog
  • Tab cycles 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>
On this page