Skip to main content

Dialog

Present a labeled modal task with structured content and dismissal.

Use Dialog when an action or form needs focused attention with a labeled modal surface. The trigger and content share disclosure state through the root.

Basic usage#

import { Button, Dialog } from 'moraine'

export function Example() {
  return (
    <Dialog>
      <Dialog.Trigger as={Button}>Open</Dialog.Trigger>
      <Dialog.Content title="Settings">
        <Dialog.Body>Preferences</Dialog.Body>
        <Dialog.Footer>
          <Dialog.Close as={Button}>Done</Dialog.Close>
        </Dialog.Footer>
      </Dialog.Content>
    </Dialog>
  )
}

Playground#

Props
Slots

Anatomy#

Dialog [component; no DOM]
├── Dialog.Trigger [part; slot=trigger]
├── Dialog.Close [part]
├── overlay [slot]
└── Dialog.Content [part; slot=content]
    ├── Dialog.Header [part; slot=header]
    │   ├── Dialog.Title [part; slot=title]
    │   ├── Dialog.Description [part; slot=description]
    │   └── Dialog.Action [part; slot=action]
    ├── contentClose [slot]
    ├── Dialog.Body [part; slot=body]
    └── Dialog.Footer [part; slot=footer]

Content is portaled. Overlay and Content are siblings by default; scrollable overlay mode nests Content inside Overlay.

Usage#

Content composition#

Use Content title and description for simple cases:

<Dialog>
  <Dialog.Content title="Settings" description="Update your account.">
    <Dialog.Body>...</Dialog.Body>
    <Dialog.Footer>...</Dialog.Footer>
  </Dialog.Content>
</Dialog>

For a custom header layout, compose the anatomy parts:

<Dialog.Content>
  <Dialog.Header>
    <Dialog.Title>Settings</Dialog.Title>
    <Dialog.Description>Update your account.</Dialog.Description>
    <Dialog.Action>...</Dialog.Action>
  </Dialog.Header>
  <Dialog.Body>...</Dialog.Body>
  <Dialog.Footer>...</Dialog.Footer>
</Dialog.Content>

An explicit Dialog.Header replaces the header generated by Content’s title and description. Dialog.Title and Dialog.Description connect their IDs to the surface’s accessible name and description. Without a visible title, supply root ariaLabel or native ARIA labeling on Content.

Dialog.Action places actions beside the title. Dialog.Close is an explicit close control you can place inside the root; it is separate from Content’s automatic corner close button.

Focus and outside interaction#

modal defaults to true: focus stays inside the surface, outside content is hidden from assistive technology, and native outside pointer actions are prevented. Closing restores focus to the trigger.

Setting modal={false} allows focus to leave the surface, but does not disable Escape or outside dismissal. dismissible controls those close requests. Scroll locking is independent: preventScroll defaults to true. For interaction with the background page, also set preventScroll={false} and overlay={false}.

Styling and portal placement#

Apply class and object style directly to each rendered part. Root classes and styles set slot defaults for the family; Content’s slot maps cover only overlay, content, and contentClose. See Customization for precedence.

Set root portalMount to place Content in a chosen container. This also works for controlled surfaces without a Trigger.

Nested overlays#

Nested roots keep dismissal and focus restoration local to the active layer. Escape closes the topmost dialog or popover, then returns focus to its trigger inside the parent dialog.

import { Button, Dialog, Popover } from 'moraine'

export function NestedOverlays() {
  return (
    <Dialog>
      <Dialog.Trigger as={Button} variant="outline">
        Open nested overlays
      </Dialog.Trigger>
      <Dialog.Content
        title="Workspace settings"
        description="Review help before confirming your workspace changes."
      >
        <Dialog.Footer class="flex-wrap gap-3">
          <Popover>
            <Popover.Trigger as={Button} variant="outline">
              View settings help
            </Popover.Trigger>
            <Popover.Content ariaLabel="Settings help" class="p-3">
              Changes apply to this workspace only.
            </Popover.Content>
          </Popover>
          <Dialog>
            <Dialog.Trigger as={Button}>Confirm workspace changes</Dialog.Trigger>
            <Dialog.Content
              title="Confirm changes"
              description="Escape closes this confirmation before closing workspace settings."
            >
              <Dialog.Footer>
                <Dialog.Close
                  as={Button}
                  variant="outline"
                  class="px-3 py-1.5 h-auto w-auto static"
                >
                  Return to settings
                </Dialog.Close>
              </Dialog.Footer>
            </Dialog.Content>
          </Dialog>
        </Dialog.Footer>
      </Dialog.Content>
    </Dialog>
  )
}

State and dismissal#

Use controlled open with onOpenChange when the surrounding flow owns visibility. dismissible={false} blocks outside and Escape dismissal and calls onClosePrevent; provide a clear explicit close path in that case.

import { Badge, Button, Dialog } from 'moraine'
import { createSignal, Show } from 'solid-js'

export function StateDismissal() {
  const [open, setOpen] = createSignal(false)
  const [dismissible, setDismissible] = createSignal(false)
  const [preventedAttempts, setPreventedAttempts] = createSignal(0)

  return (
    <div class="flex flex-wrap gap-4 items-center">
      <Button onClick={() => setOpen(true)}>
        Open {dismissible() ? 'Dismissible' : 'Non-Dismissible'} Dialog
      </Button>

      <Button
        variant="outline"
        onClick={() => {
          setDismissible((prev) => !prev)
          setPreventedAttempts(0)
        }}
      >
        Mode: {dismissible() ? 'Dismissible' : 'Strict (Buttons Only)'}
      </Button>

      <Dialog
        open={open()}
        onOpenChange={setOpen}
        dismissible={dismissible()}
        onClosePrevent={() => setPreventedAttempts((c) => c + 1)}
      >
        <Dialog.Content
          title="Unsaved Configuration Changes"
          description="Explicit confirmation is required before navigating away."
        >
          <Dialog.Body>
            <div class="py-2 space-y-3">
              <p class="text-sm text-muted-foreground">
                {dismissible()
                  ? 'Press Escape or click the backdrop to dismiss.'
                  : 'Clicking outside or pressing Escape is blocked. Use the action buttons below.'}
              </p>
              <Show when={preventedAttempts() > 0}>
                <Badge variant="outline" class="text-destructive border-destructive">
                  Blocked {preventedAttempts()} outside dismissal attempt(s)
                </Badge>
              </Show>
            </div>
          </Dialog.Body>
          <Dialog.Footer>
            <div class="flex gap-2 w-full justify-end">
              <Button
                variant="outline"
                onClick={() => {
                  setOpen(false)
                  setPreventedAttempts(0)
                }}
              >
                Discard Changes
              </Button>
              <Button
                onClick={() => {
                  setOpen(false)
                  setPreventedAttempts(0)
                }}
              >
                Save & Apply
              </Button>
            </div>
          </Dialog.Footer>
        </Dialog.Content>
      </Dialog>
    </div>
  )
}

Structure and lifecycle#

Use root scrollable for long content or fullscreen for a full-viewport task. onExitComplete runs after the overlay and content finish their exit animations.

import { Badge, Button, Dialog } from 'moraine'
import { createSignal } from 'solid-js'

export function StructureLifecycle() {
  const [exits, setExits] = createSignal(0)
  const [status, setStatus] = createSignal('Idle')

  return (
    <div class="flex flex-wrap gap-4 items-center">
      <Dialog
        onExitComplete={() => {
          setExits((c) => c + 1)
          setStatus('Exit animation completed')
        }}
      >
        <Dialog.Trigger as={Button}>Open Provisioning Dialog</Dialog.Trigger>
        <Dialog.Content
          title="Provision Production Database"
          description="Configure clustering, replication nodes, and automated backup schedules."
        >
          <Dialog.Body>
            <div class="text-muted-foreground leading-relaxed py-2 space-y-2">
              <p>
                Provisioning will allocate dedicated compute instances and initialize encryption
                keys.
              </p>
            </div>
          </Dialog.Body>
          <Dialog.Footer>
            <div class="flex gap-2 w-full justify-end">
              <Button variant="outline">Cancel</Button>
              <Button>Provision Cluster</Button>
            </div>
          </Dialog.Footer>
        </Dialog.Content>
      </Dialog>

      <div class="text-xs text-muted-foreground flex gap-2 items-center">
        <span>Lifecycle status:</span>
        <Badge variant="outline">{status()}</Badge>
        <span>(Completed exits: {exits()})</span>
      </div>
    </div>
  )
}

Keyboard interaction#

Key Description
Esc Requests dismissal when dismissible={true}.
⇥ Moves focus forward through focusable controls inside the dialog.
Shift + Tab Moves focus backward through focusable controls inside the dialog.

Examples#

Destructive confirmation#

Keep the item visible until the user confirms the action. Cancel and backdrop dismissal leave it intact.

import { Button, Dialog } from 'moraine'
import { createSignal, Show } from 'solid-js'

export function DeleteConfirmation() {
  const [exists, setExists] = createSignal(true)
  const [open, setOpen] = createSignal(false)

  return (
    <div class="flex gap-3 items-center">
      <Show when={exists()} fallback={<p class="text-sm">Saved filter removed.</p>}>
        <span class="text-sm">Saved filter: Assigned to me</span>
        <Dialog open={open()} onOpenChange={setOpen}>
          <Dialog.Trigger as={Button} variant="outline" size="sm">
            Delete
          </Dialog.Trigger>
          <Dialog.Content
            title="Delete saved filter?"
            description="Assigned to me will be removed from your saved filters."
          >
            <Dialog.Body>
              <p class="text-sm">You can create another filter later.</p>
            </Dialog.Body>
            <Dialog.Footer>
              <div class="flex gap-2 w-full justify-end">
                <Dialog.Close as={Button} variant="outline">
                  Cancel
                </Dialog.Close>
                <Button
                  variant="destructive"
                  onClick={() => {
                    setExists(false)
                    setOpen(false)
                  }}
                >
                  Delete filter
                </Button>
              </div>
            </Dialog.Footer>
          </Dialog.Content>
        </Dialog>
      </Show>
    </div>
  )
}

Scrollable and fullscreen content#

import { Button, Dialog } from 'moraine'
import { For } from 'solid-js'

export function ScrollableFullscreen() {
  const SCROLLABLE_LINES = Array.from(
    { length: 100 },
    (_, index) => `Release note line ${index + 1}`,
  )

  return (
    <div class="flex flex-wrap gap-3 items-center">
      <Dialog scrollable>
        <Dialog.Trigger as={Button} variant="secondary">
          Overlay scroll dialog
        </Dialog.Trigger>
        <Dialog.Content title="Release Notes" description="Long content scrolls with the overlay.">
          <Dialog.Body>
            <div class="space-y-1">
              <For each={SCROLLABLE_LINES}>
                {(line) => <p class="text-sm text-foreground">{line}</p>}
              </For>
            </div>
          </Dialog.Body>
        </Dialog.Content>
      </Dialog>
      <Dialog fullscreen>
        <Dialog.Trigger as={Button} variant="secondary">
          Full screen dialog
        </Dialog.Trigger>
        <Dialog.Content title="Release Notes" description="Full screen dialog content.">
          <Dialog.Body>
            <div class="space-y-1">
              <For each={SCROLLABLE_LINES}>
                {(line) => <p class="text-sm text-foreground">{line}</p>}
              </For>
            </div>
          </Dialog.Body>
        </Dialog.Content>
      </Dialog>
    </div>
  )
}

Controlled lifecycle#

import { Button, Dialog } from 'moraine'
import { createSignal } from 'solid-js'

export function ControlledLifecycle() {
  const [open, setOpen] = createSignal(false)
  const [exitCount, setExitCount] = createSignal(0)

  return (
    <div class="flex gap-3 items-center">
      <Button variant="outline" onClick={() => setOpen(true)}>
        Open dialog
      </Button>
      <p class="text-sm text-muted-foreground">Completed exits: {exitCount()}</p>
      <Dialog
        open={open()}
        onOpenChange={setOpen}
        onExitComplete={() => setExitCount((count) => count + 1)}
      >
        <Dialog.Content title="Controlled dialog">
          <Dialog.Body>
            <p class="text-sm">The parent owns visibility and observes completed exit motion.</p>
          </Dialog.Body>
          <Dialog.Footer>
            <Button onClick={() => setOpen(false)}>Close</Button>
          </Dialog.Footer>
        </Dialog.Content>
      </Dialog>
    </div>
  )
}

Attributes#

Attributes
data-closedSlot: dialog-trigger, dialog-content, dialog-overlayDescription: Present when disclosure or transition content is closed.
data-disabledSlot: dialog-triggerDescription: Present when the component, slot, or item is disabled.
data-expandedSlot: dialog-trigger, dialog-content, dialog-overlayDescription: Present when the panel, accordion, or menu is expanded.
data-overlay-scrollSlot: dialog-overlayDescription: Present when scrolling is owned by the overlay.
data-footerSlot: dialog-bodyDescription: Present when the component renders footer content.
data-headerSlot: dialog-bodyDescription: Present when the component renders header content.
data-scrollSlot: dialog-bodyDescription: Present when the content region owns scrolling.

Props#

Dialog#

Prop

Trigger#

Renders a <button> element by default.

Prop

Content#

Props for Dialog.Content. Renders a <div> element by default.

Prop

Header#

Renders a <div> element by default.

Prop

Title#

Renders a <h2> element by default.

Prop

Description#

Renders a <p> element by default.

Prop

Action#

Renders a <div> element by default.

Prop

Body#

Renders a <div> element by default.

Prop

Renders a <div> element by default.

Prop

Close#

Renders a <button> element by default.

Prop