---
title: Modal
description: Compose a modal trigger, backdrop, and content surface.
package: moraine
version: 0.6.0
repository: https://github.com/subframe7536/moraine
---

# Modal

> Compose a modal trigger, backdrop, and content surface.

Use Modal when you need to compose backdrop, trigger, and content directly. Use [Dialog](https://moraine.subf.dev/components/dialog.md) for a structured title, description, body, and footer.

## Basic usage

```tsx
import { Button, Modal } from 'moraine'

export function Example() {
  return (
    <Modal>
      <Modal.Trigger as={Button}>Open</Modal.Trigger>
      <Modal.Portal>
        <Modal.Overlay />
        <Modal.Content ariaLabel="Details">
          Modal content<Modal.Close as={Button}>Close</Modal.Close>
        </Modal.Content>
      </Modal.Portal>
    </Modal>
  )
}
```

## Anatomy

```text
Modal [component; no DOM]
├── Modal.Trigger [part]
└── Modal.Portal [part]
    ├── Modal.Overlay [part; slot=overlay]
    └── Modal.Content [part; slot=content]
```

Portal owns the mounted Overlay and Content subtree and preserves it through exit motion.

## Usage

### Composition

Place `Modal.Overlay` and `Modal.Content` inside the same `Modal.Portal`. The Portal mounts them
when open and keeps them present through exit animations. Without it, both parts render in place
and your application controls their mounting. One root owns one active Content; use a nested
Modal root for a second surface.

```tsx
import { Button, Modal } from 'moraine'

export function ModalComposition() {
  return (
    <Modal>
      <Modal.Trigger as={Button}>Open Modal Surface</Modal.Trigger>
      <Modal.Portal>
        <Modal.Overlay />
        <Modal.Content ariaLabel="Custom Surface">
          {(context) => (
            <div class="p-4 b-1 b-border rounded-xl bg-background space-y-4">
              <h3 class="text-base font-semibold">Custom Surface</h3>
              <p class="text-xs text-muted-foreground">
                Modal coordinates overlay, focus trap, and portal rendering.
              </p>
              <div class="flex justify-end">
                <Button size="xs" onClick={context.close}>
                  Close
                </Button>
              </div>
            </div>
          )}
        </Modal.Content>
      </Modal.Portal>
    </Modal>
  )
}
```

Use [Dialog](https://moraine.subf.dev/components/dialog.md) or [Sheet](https://moraine.subf.dev/components/sheet.md) for structured title, description,
body, and footer regions. Their Content parts include a Portal internally.

### 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. Provide an accessible name with
`ariaLabel` or `ariaLabelledBy` on `Modal.Content`.

Setting `modal={false}` allows focus to leave, but Escape and outside interactions still request
closure according to `dismissible`. Scroll locking is independent: `preventScroll` defaults to
`true`. To allow background page interaction, set `preventScroll={false}` and omit `Modal.Overlay`.

### Styling and portal placement

The root renders no element. Apply `class` and object `style` to rendered parts; root `classes`
and `styles` set their slot defaults. See [Customization](https://moraine.subf.dev/docs/customization.md#the-4-layer-override-hierarchy).

Set root `portalMount` for a chosen container, including controlled surfaces without a Trigger.
`Modal.Portal mount` overrides that default.

### State and exit lifecycle

Use `open` with `onOpenChange` for controlled visibility. `onExitComplete` waits for the overlay and content exit animations to finish. An empty `class` does not disable built-in styles.

```tsx
import { Button, Modal } from 'moraine'
import { createSignal } from 'solid-js'

export function ModalLifecycle() {
  const [open, setOpen] = createSignal(false)
  const [log, setLog] = createSignal('Idle')

  return (
    <div class="space-y-3">
      <Button onClick={() => setOpen(true)}>Open Managed Modal</Button>
      <Modal
        open={open()}
        onOpenChange={setOpen}
        onExitComplete={() => setLog('Exit transition fully completed')}
      >
        <Modal.Portal>
          <Modal.Overlay />
          <Modal.Content ariaLabel="Lifecycle Monitored">
            {(context) => (
              <div class="p-4 b-1 b-border rounded-xl bg-background space-y-4">
                <h3 class="text-base font-semibold">Lifecycle Monitored</h3>
                <p class="text-xs text-muted-foreground">
                  Exit callbacks fire after presence transitions resolve.
                </p>
                <div class="flex justify-end">
                  <Button size="xs" onClick={context.close}>
                    Dismiss
                  </Button>
                </div>
              </div>
            )}
          </Modal.Content>
        </Modal.Portal>
      </Modal>
      <p class="text-xs text-muted-foreground">
        Lifecycle log: <span class="text-foreground font-mono">{log()}</span>
      </p>
    </div>
  )
}
```

### Keyboard interaction

| Key                    | Description                                        |
| ---------------------- | -------------------------------------------------- |
| <kbd>Escape</kbd>      | Requests dismissal when `dismissible={true}`.      |
| <kbd>Tab</kbd>         | Moves focus forward within the modal focus scope.  |
| <kbd>Shift + Tab</kbd> | Moves focus backward within the modal focus scope. |

## Examples

### Controlled state

```tsx
import { Button, Modal } from 'moraine'
import { createSignal } from 'solid-js'

export function Controlled() {
  const [open, setOpen] = createSignal(false)

  return (
    <div class="flex flex-wrap gap-3">
      <Button variant="outline" onClick={() => setOpen(true)}>
        Open controlled modal
      </Button>
      <Modal open={open()} onOpenChange={setOpen}>
        <Modal.Portal>
          <Modal.Overlay />
          <Modal.Content ariaLabel="Controlled modal">
            {(context) => (
              <div class="p-4 gap-4 grid">
                <p class="text-sm text-foreground">
                  The parent owns the open state through onOpenChange.
                </p>
                <Button class="justify-self-end" onClick={context.close}>
                  Close
                </Button>
              </div>
            )}
          </Modal.Content>
        </Modal.Portal>
      </Modal>
    </div>
  )
}
```

### Without backdrop

```tsx
import { Button, Modal } from 'moraine'

export function NoOverlay() {
  return (
    <Modal>
      <Modal.Trigger as={Button} variant="outline">
        Open without backdrop
      </Modal.Trigger>
      <Modal.Portal>
        <Modal.Content ariaLabel="Modal without a backdrop">
          {(context) => (
            <div class="p-4 gap-4 grid">
              <p class="text-sm text-foreground">
                Omit Modal.Overlay when the host surface provides context.
              </p>
              <Button class="justify-self-end" onClick={context.close}>
                Close
              </Button>
            </div>
          )}
        </Modal.Content>
      </Modal.Portal>
    </Modal>
  )
}
```

### Draggable title

```tsx
import { Button, Icon, Modal } from 'moraine'
import { createSignal } from 'solid-js'

export function Draggable() {
  const [position, setPosition] = createSignal({ x: 0, y: 0 })
  const [isDragging, setIsDragging] = createSignal(false)

  const handlePointerDown = (event: PointerEvent) => {
    if (event.button !== 0) {
      return
    }

    const target = event.target as HTMLElement
    if (target.closest('button, a, input, [role="button"]')) {
      return
    }

    event.preventDefault()
    const currentTarget = event.currentTarget as HTMLElement
    currentTarget.setPointerCapture(event.pointerId)

    setIsDragging(true)
    const startX = event.clientX
    const startY = event.clientY
    const startPos = position()

    const handlePointerMove = (moveEvent: PointerEvent) => {
      setPosition({
        x: Math.round(startPos.x + (moveEvent.clientX - startX)),
        y: Math.round(startPos.y + (moveEvent.clientY - startY)),
      })
    }

    const handlePointerUp = (upEvent: PointerEvent) => {
      currentTarget.removeEventListener('pointermove', handlePointerMove)
      currentTarget.removeEventListener('pointerup', handlePointerUp)
      currentTarget.removeEventListener('pointercancel', handlePointerUp)
      try {
        currentTarget.releasePointerCapture(upEvent.pointerId)
      } catch {}
      setIsDragging(false)
    }

    currentTarget.addEventListener('pointermove', handlePointerMove)
    currentTarget.addEventListener('pointerup', handlePointerUp)
    currentTarget.addEventListener('pointercancel', handlePointerUp)
  }

  const resetPosition = () => setPosition({ x: 0, y: 0 })

  return (
    <Modal
      onOpenChange={(open) => {
        if (!open) {
          resetPosition()
        }
      }}
    >
      <Modal.Trigger as={Button} variant="outline" leading="i-lucide:move">
        Open Draggable Modal
      </Modal.Trigger>
      <Modal.Portal>
        <Modal.Overlay />
        <Modal.Content
          ariaLabel="Draggable Modal"
          style={{
            translate: `calc(-50% + ${position().x}px) calc(-50% + ${position().y}px)`,
            transition: isDragging() ? 'none' : 'translate 150ms ease-out',
          }}
        >
          {(context) => (
            <div class="b-1 b-border rounded-xl bg-card flex flex-col max-w-md w-full shadow-xl overflow-hidden">
              <div
                class="px-4 py-3 border-b border-border bg-muted/50 flex cursor-grab select-none items-center justify-between active:cursor-grabbing"
                onPointerDown={handlePointerDown}
              >
                <div class="flex gap-2 items-center">
                  <Icon name="i-lucide:grip-vertical" class="text-muted-foreground size-4" />
                  <h3 class="text-sm text-foreground font-semibold">Draggable Window</h3>
                </div>
                <Button
                  variant="ghost"
                  size="xs"
                  class="p-0 rounded-md size-6"
                  onClick={context.close}
                  aria-label="Close"
                >
                  <Icon name="i-lucide:x" class="size-3.5" />
                </Button>
              </div>

              <div class="p-4 space-y-3">
                <p class="text-xs text-muted-foreground leading-relaxed">
                  Click and drag the header title bar to reposition this modal dialog across the
                  viewport.
                </p>
                <div class="text-xs text-muted-foreground font-mono p-2.5 rounded-lg bg-muted flex items-center justify-between">
                  <span>Offset:</span>
                  <span>
                    X: {position().x}px, Y: {position().y}px
                  </span>
                </div>
              </div>

              <div class="p-3 border-t border-border bg-card flex items-center justify-between">
                <Button
                  size="xs"
                  variant="ghost"
                  leading="i-lucide:rotate-ccw"
                  onClick={resetPosition}
                  disabled={position().x === 0 && position().y === 0}
                >
                  Reset Position
                </Button>
                <Button size="xs" variant="default" onClick={context.close}>
                  Done
                </Button>
              </div>
            </div>
          )}
        </Modal.Content>
      </Modal.Portal>
    </Modal>
  )
}
```

### Exit lifecycle

```tsx
import { Button, Modal } from 'moraine'
import { createSignal } from 'solid-js'

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

  return (
    <div class="flex gap-3 items-center">
      <Button onClick={() => setOpen(true)}>Open modal</Button>
      <p class="text-sm text-muted-foreground">Completed exits: {exitCount()}</p>
      <Modal
        open={open()}
        onOpenChange={setOpen}
        onExitComplete={() => setExitCount((count) => count + 1)}
      >
        <Modal.Portal>
          <Modal.Overlay />
          <Modal.Content ariaLabel="Exit lifecycle example">
            {({ close }) => (
              <div class="p-5 rounded-xl bg-card shadow-xl">
                <p class="text-sm mb-4">
                  Close this modal and watch the count update after exit motion.
                </p>
                <Button onClick={close}>Close</Button>
              </div>
            )}
          </Modal.Content>
        </Modal.Portal>
      </Modal>
    </div>
  )
}
```

## Attributes

| Attributes | Slot | Description |
| --- | --- | --- |
| `data-closed` | `modal-overlay`, `modal-content` | Present when disclosure or transition content is closed. |
| `data-expanded` | `modal-overlay`, `modal-content` | Present when the panel, accordion, or menu is expanded. |
| `data-overlay-scroll` | `modal-overlay` | Present when scrolling is owned by the overlay. |

## Props

### Modal

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| defaultOpen | boolean \| undefined | false | Initial open state when uncontrolled. |
| dismissible | boolean \| undefined | true | Whether outside interaction and Escape should dismiss the shell. |
| id | string \| undefined | — | Unique identifier used to derive the content id. |
| modal | boolean \| undefined | true | Whether the surface contains focus, restores focus on close, hides outside content from<br>assistive technology, and prevents the native default action of outside pointer events.<br>Outside pointer and Escape dismissal remain controlled by `dismissible`. |
| onClosePrevent | (() => void) \| undefined | — | Called when a dismissal attempt is blocked. |
| onExitComplete | (() => void) \| undefined | — | Called after the modal has fully finished its exit motion. |
| onOpenChange | ((open: boolean) => void) \| undefined | — | Called whenever the open state changes. |
| open | boolean \| undefined | — | Controlled open state. |
| portalMount | Node \| undefined | — | Default destination for Modal.Portal; defaults to the trigger document body. |
| preventScroll | boolean \| undefined | true | Whether body scroll should be locked while the shell is present. |
| children | JSX.Element \| undefined | — | Composed trigger and content primitives. |
| classes | Classes \| undefined | — | Family slot class defaults for this Modal instance. |
| styles | Styles \| undefined | — | Family slot style defaults for this Modal instance. |

### Modal.Trigger

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| disabled | boolean \| undefined | — | Whether this trigger is disabled. |
| as | T \| undefined | 'button' | Element or component to render as. |
| children | JSX.Element \| undefined | — | Trigger label and visual content. |
| class | SlotClassValue \| undefined | — | Class applied to the component root or trigger element. |
| style | SlotStyleValue \| undefined | — | Style applied to the component root or trigger element. |

### Modal.Portal

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| mount | Node \| undefined | — | Destination for the modal parts; defaults to the trigger document body. |
| children | JSX.Element \| undefined | — | Overlay and content parts rendered in the same portal. |

### Modal.Overlay

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| ref | ((element: HTMLDivElement \| undefined) => void) \| undefined | — | Receives the mounted overlay element and `undefined` when it unmounts. |
| scrollable | boolean \| undefined | — | Whether the overlay should scroll its content. |
| children | JSX.Element \| undefined | — | Optional elements rendered inside the overlay. |
| class | SlotClassValue \| undefined | — | Class applied to the component root or trigger element. |
| style | SlotStyleValue \| undefined | — | Style applied to the component root or trigger element. |

### Modal.Content

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| ariaDescribedBy | string \| undefined | — | Id of the element that describes the modal content. |
| ariaLabel | string \| undefined | — | Accessible name used when no visible label is available. |
| ariaLabelledBy | string \| undefined | — | Id of the element that labels the modal content. |
| children* | JSX.Element \| ((props: ContentRenderProps) => JSX.Element) | — | Content or render function inside the modal content surface. |
| class | SlotClassValue \| undefined | — | Class applied to the component root or trigger element. |
| style | SlotStyleValue \| undefined | — | Style applied to the component root or trigger element. |

### Modal.Close

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| disabled | boolean \| undefined | — | Whether this trigger is disabled. |
| as | T \| undefined | 'button' | Element or component to render as. |
| children | JSX.Element \| undefined | — | Trigger label and visual content. |
| class | SlotClassValue \| undefined | — | Class applied to the component root or trigger element. |
| style | SlotStyleValue \| undefined | — | Style applied to the component root or trigger element. |
