Skip to main content

Modal

Compose a modal trigger, backdrop, and content surface.

Use Modal when you need to compose backdrop, trigger, and content directly. Use Dialog for a structured title, description, body, and footer.

Basic usage#

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>
  )
}

Playground#

Props
Slots

Anatomy#

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.

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 or Sheet 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.

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.

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
Esc Requests dismissal when dismissible={true}.
⇥ Moves focus forward within the modal focus scope.
Shift + Tab Moves focus backward within the modal focus scope.

Examples#

Controlled state#

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#

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#

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#

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
data-closedSlot: modal-overlay, modal-contentDescription: Present when disclosure or transition content is closed.
data-expandedSlot: modal-overlay, modal-contentDescription: Present when the panel, accordion, or menu is expanded.
data-overlay-scrollSlot: modal-overlayDescription: Present when scrolling is owned by the overlay.

Props#

Modal#

Prop

Trigger#

Renders a <button> element by default.

Prop

Portal#

Prop

Overlay#

Renders a <div> element by default.

Prop

Content#

Renders a <div> element by default.

Prop

Close#

Renders a <button> element by default.

Prop