Skip to content

Dialog ​

Open a native modal dialog for a focused task. A Button targets its id with command="show-modal"; you supply the heading, description, form, actions, and Tailwind classes.

Preview Live · Vue
Usage
vue
<Button commandfor="delete-project" command="show-modal">
  Delete project
</Button>

<Dialog id="delete-project" aria-labelledby="delete-project-title">
  <h2 id="delete-project-title">Delete this project?</h2>
  <p>This cannot be undone.</p>

  <form method="dialog">
    <Button type="submit" value="cancel" autofocus>Cancel</Button>
    <Button type="submit" value="delete">Delete project</Button>
  </form>
</Dialog>
jsx
;<>
  <Button commandfor="delete-project" command="show-modal">
    Delete project
  </Button>

  <Dialog id="delete-project" aria-labelledby="delete-project-title">
    <h2 id="delete-project-title">Delete this project?</h2>
    <p>This cannot be undone.</p>

    <form method="dialog">
      <Button type="submit" value="cancel" autoFocus>
        Cancel
      </Button>
      <Button type="submit" value="delete">
        Delete project
      </Button>
    </form>
  </Dialog>
</>
svelte
<Button commandfor="delete-project" command="show-modal">Delete project</Button>

<Dialog id="delete-project" aria-labelledby="delete-project-title">
  <h2 id="delete-project-title">Delete this project?</h2>
  <p>This cannot be undone.</p>

  <form method="dialog">
    <Button type="submit" value="cancel" autofocus>Cancel</Button>
    <Button type="submit" value="delete">Delete project</Button>
  </form>
</Dialog>
View source Dialog.vue
Dialog.vue
vue
<script setup>
import {
  computed,
  nextTick,
  onBeforeUnmount,
  onMounted,
  ref,
  useAttrs,
  watch
} from 'vue'
import { twMerge } from 'tailwind-merge'

defineOptions({ inheritAttrs: false })

const props = defineProps({
  /** The native id targeted by a button's `commandfor` attribute. */
  id: { type: String, default: undefined },
  /** Framework-native controlled state. Omit for native uncontrolled use. */
  open: { type: Boolean, default: undefined },
  /** Initial state when `open` is not controlled. */
  defaultOpen: { type: Boolean, default: false },
  /** Whether Escape, platform dismissal, and backdrop clicks may close it. */
  dismissible: { type: Boolean, default: true }
})

const emit = defineEmits(['update:open'])
const attrs = useAttrs()
const dialog = ref()
const internalOpen = ref(props.defaultOpen)
const nativeOpen = ref(false)
const isControlled = computed(() => props.open !== undefined)
const desiredOpen = computed(() =>
  isControlled.value ? props.open : internalOpen.value
)
const dialogAttrs = computed(() => {
  const {
    class: _class,
    closedby: _closedby,
    open: _open,
    'data-slot': _dataSlot,
    'data-state': _dataState,
    onBeforetoggle: _onBeforetoggle,
    onBeforeToggle: _onBeforeToggle,
    onCancel: _onCancel,
    onClick: _onClick,
    onClose: _onClose,
    onToggle: _onToggle,
    ...rest
  } = attrs

  return rest
})
const dialogClasses = computed(() =>
  twMerge(
    [
      'm-auto w-[calc(100vw-2rem)] max-w-lg overflow-hidden rounded-lg border border-gray-200 bg-white p-6 text-gray-950 shadow-xl outline-none',
      'backdrop:bg-black/50',
      'dark:border-gray-700 dark:bg-gray-950 dark:text-white'
    ],
    attrs.class
  )
)

let commandRoot
let previousDocumentOverflow = ''
let scrollLocked = false
let fallbackInvoker

function resolveInvoker(source, element) {
  const activeElement =
    typeof document === 'undefined' ? undefined : document.activeElement

  return [source, activeElement].find(
    (candidate) =>
      candidate &&
      candidate !== document.body &&
      candidate !== document.documentElement &&
      candidate !== element &&
      !element.contains(candidate) &&
      candidate.isConnected &&
      typeof candidate.focus === 'function'
  )
}

function callListener(listener, event) {
  for (const callback of Array.isArray(listener) ? listener : [listener]) {
    callback?.(event)
  }
}

function lockScroll() {
  if (scrollLocked || typeof document === 'undefined') return
  previousDocumentOverflow = document.documentElement.style.overflow
  document.documentElement.style.overflow = 'hidden'
  scrollLocked = true
}

function unlockScroll() {
  if (!scrollLocked || typeof document === 'undefined') return
  document.documentElement.style.overflow = previousDocumentOverflow
  scrollLocked = false
}

function observeNativeOpen(nextOpen) {
  const shouldNotify = desiredOpen.value !== nextOpen
  nativeOpen.value = nextOpen

  if (!isControlled.value) internalOpen.value = nextOpen
  if (nextOpen) lockScroll()
  else unlockScroll()
  if (shouldNotify) emit('update:open', nextOpen)
}

function showModal(source) {
  const element = dialog.value
  if (!element || element.open) return

  fallbackInvoker = resolveInvoker(source, element)
  element.showModal()
  observeNativeOpen(true)
}

function close(returnValue) {
  const element = dialog.value
  if (!element?.open) return

  element.close(returnValue)
  observeNativeOpen(false)
}

function requestClose(returnValue) {
  const element = dialog.value
  if (!element?.open) return

  if (typeof element.requestClose === 'function') {
    element.requestClose(returnValue)
    return
  }

  const event = new Event('cancel', { cancelable: true })
  if (element.dispatchEvent(event)) close(returnValue)
}

function handleBeforeToggle(event) {
  callListener(attrs.onBeforetoggle ?? attrs.onBeforeToggle, event)
}

function handleToggle(event) {
  observeNativeOpen(event.newState === 'open' || dialog.value?.open === true)
  callListener(attrs.onToggle, event)
}

function handleCancel(event) {
  if (!props.dismissible) event.preventDefault()
  callListener(attrs.onCancel, event)
}

function handleClose(event) {
  observeNativeOpen(false)

  if (fallbackInvoker?.isConnected) {
    fallbackInvoker.focus({ preventScroll: true })
  }
  fallbackInvoker = undefined
  callListener(attrs.onClose, event)
}

function clickIsOutsideDialog(event) {
  const rect = event.currentTarget.getBoundingClientRect()

  return (
    event.clientX < rect.left ||
    event.clientX > rect.right ||
    event.clientY < rect.top ||
    event.clientY > rect.bottom
  )
}

function handleClick(event) {
  callListener(attrs.onClick, event)

  if (
    event.defaultPrevented ||
    !props.dismissible ||
    'closedBy' in event.currentTarget ||
    event.target !== event.currentTarget ||
    !clickIsOutsideDialog(event)
  ) {
    return
  }

  requestClose()
}

function commandButton(event) {
  return (event.composedPath?.() ?? [event.target]).find(
    (element) =>
      element?.tagName === 'BUTTON' &&
      element.getAttribute('commandfor') === props.id
  )
}

function handleFallbackCommand(event) {
  const button = commandButton(event)
  if (!button || button.matches(':disabled')) return

  const command = button.getAttribute('command')
  if (command === 'show-modal') showModal(button)
  else if (command === 'close') close(button.value)
  else if (command === 'request-close') requestClose(button.value)
}

function supportsInvokerCommands() {
  return (
    typeof HTMLButtonElement !== 'undefined' &&
    'commandForElement' in HTMLButtonElement.prototype
  )
}

async function syncDesiredOpen() {
  await nextTick()
  const element = dialog.value
  if (!element) return

  if (desiredOpen.value && !element.open) showModal()
  else if (!desiredOpen.value && element.open) close()
  else observeNativeOpen(element.open)
}

watch(desiredOpen, syncDesiredOpen, { flush: 'post' })

onMounted(() => {
  commandRoot = dialog.value?.getRootNode?.() ?? document
  if (!supportsInvokerCommands() && props.id) {
    commandRoot.addEventListener('click', handleFallbackCommand)
  }
  syncDesiredOpen()
})

onBeforeUnmount(() => {
  commandRoot?.removeEventListener('click', handleFallbackCommand)
  if (dialog.value?.open) dialog.value.close()
  unlockScroll()
})

defineExpose({ dialog, showModal, close, requestClose })
</script>

<template>
  <dialog
    ref="dialog"
    v-bind="dialogAttrs"
    :id="id"
    :closedby="dismissible ? 'any' : 'none'"
    data-slot="dialog"
    :data-state="nativeOpen ? 'open' : 'closed'"
    :class="dialogClasses"
    @beforetoggle="handleBeforeToggle"
    @toggle="handleToggle"
    @cancel="handleCancel"
    @close="handleClose"
    @click="handleClick"
  >
    <slot />
  </dialog>
</template>

Installation ​

One command detects Vue, React, or Svelte and installs the framework-native source:

Run from your application’s directory. Klean detects your framework and copies the matching source.

Terminal
npx klean-ui add dialog

Source you own. Edit the installed files to make them yours.

When to use ​

Use Dialog for a modal task, consequential confirmation, or focused interaction that must make the page behind it inert.

When not to use ​

Use Popover for non-modal content, Menu for a compact action list, and Toast for feedback that must not interrupt the current task.

Usage ​

Copy the example above for your framework.

Native contract ​

Opening with showModal() puts the element in the top layer. The browser owns the inert background, modal focus containment, initial focus, Escape behavior, and the native <form method="dialog"> completion path. Klean adds controlled state observation, exact invoker focus return, a closedby fallback, safe scroll restoration, and caller-winning Tailwind defaults.

Every Dialog needs an accessible name. Point aria-labelledby at a visible heading or provide aria-label. Use aria-describedby for a short description; let longer structured content speak through its native headings and paragraphs. Put autofocus on the safest initial action—usually Cancel for destructive work.

API ​

PurposeVueReactSvelte
Native targetididid
Open statev-model:openopen, onOpenChangebind:open
Initial statedefault-opendefaultOpendefaultOpen
Ambient dismissaldismissibledismissibledismissible
StylingclassclassNameclass
Native capabilitycomponent refHTMLDialogElement refcomponent binding

Vue and Svelte expose showModal(), close(returnValue), and requestClose(returnValue). React forwards the real HTMLDialogElement. Prefer native commands and <form method="dialog"> for ordinary opening and completion; observe state only when application behavior genuinely needs it.

Durable behavior ​

  • Escape, platform close, and backdrop dismissal follow dismissible.
  • The native dialog owns focus entry and containment. Klean remembers the exact external invoker for native-command, imperative, and controlled opening, then restores it after close when it is still connected.
  • Background scroll returns to its previous value after close and unmount.
  • Explicit completion remains available when ambient dismissal is disabled.
  • Open state stays ephemeral unless the Dialog represents a shareable resource.
  • No motion, product tone, or action layout is imposed by Klean.

Product recipes ​

Use Tailwind to match the surrounding interface. Keep the heading, consequence, safest initial action, and completion value explicit in your markup.

Preview Live · Vue
View source product-dialogs.vue
product-dialogs.vue
<script setup>
import Button from '~/components/ui/button/Button.vue'
import Dialog from '~/components/ui/dialog/Dialog.vue'
</script>

<template>
  <section class="bg-[#f7f3eb] p-8">
    <p
      class="mb-5 text-xs font-medium uppercase tracking-[0.18em] text-black/60"
    >
      Hagfish / invoice editor
    </p>
    <Button
      commandfor="delete-invoice"
      command="show-modal"
      class="rounded-lg border-2 border-black bg-black text-white hover:bg-white hover:text-black hover:shadow-[4px_4px_0_0_rgba(0,0,0,1)]"
    >
      Delete draft
    </Button>
    <Dialog
      id="delete-invoice"
      aria-labelledby="delete-invoice-title"
      aria-describedby="delete-invoice-description"
      class="max-w-md rounded-2xl border-2 border-black p-6 shadow-[4px_4px_0_0_rgba(0,0,0,1)]"
    >
      <p class="text-xs font-medium uppercase tracking-wider text-red-700">
        Permanent action
      </p>
      <h2 id="delete-invoice-title" class="mt-1 text-xl font-semibold">
        Delete this draft invoice?
      </h2>
      <p
        id="delete-invoice-description"
        class="mt-4 text-sm leading-6 text-black/60"
      >
        INV-1042 and its public link will be permanently removed.
      </p>
      <form
        method="dialog"
        class="mt-6 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end"
      >
        <Button
          type="submit"
          value="cancel"
          autofocus
          class="border-2 border-black bg-white text-black hover:bg-black hover:text-white"
        >
          Cancel
        </Button>
        <Button
          type="submit"
          value="delete"
          class="border-2 border-red-700 bg-red-700 text-white hover:bg-white hover:text-red-700"
        >
          Delete invoice
        </Button>
      </form>
    </Dialog>
  </section>

  <section class="dark bg-gray-950 p-8 text-white">
    <p class="mb-5 font-mono text-xs uppercase tracking-[0.18em] text-gray-400">
      Slipway / project settings
    </p>
    <Button
      commandfor="delete-project"
      command="show-modal"
      class="min-h-9 min-w-0 bg-red-600 px-3 py-1.5 text-sm text-white hover:bg-red-700"
    >
      Delete project
    </Button>
    <Dialog
      id="delete-project"
      aria-labelledby="delete-project-title"
      aria-describedby="delete-project-description"
      class="max-w-sm border-gray-700 bg-gray-900 p-6 text-white shadow-xl"
    >
      <h2 id="delete-project-title" class="text-lg font-semibold">
        Delete project?
      </h2>
      <p id="delete-project-description" class="mt-2 text-sm text-gray-400">
        Recovery data remains unless you choose to purge it.
      </p>
      <form method="dialog" class="mt-5 flex justify-end gap-3">
        <Button
          type="submit"
          value="cancel"
          autofocus
          class="min-h-9 min-w-0 border border-gray-600 bg-transparent px-3 py-1.5 text-sm text-gray-300 hover:bg-gray-800"
        >
          Cancel
        </Button>
        <Button
          type="submit"
          value="delete"
          class="min-h-9 min-w-0 bg-red-600 px-3 py-1.5 text-sm text-white hover:bg-red-700"
        >
          Delete
        </Button>
      </form>
    </Dialog>
  </section>
</template>

Klean owns the modal behavior. Each app keeps its hierarchy, density, color, and action language at the call site.

Complete framework source ​

vue
<script setup>
import {
  computed,
  nextTick,
  onBeforeUnmount,
  onMounted,
  ref,
  useAttrs,
  watch
} from 'vue'
import { twMerge } from 'tailwind-merge'

defineOptions({ inheritAttrs: false })

const props = defineProps({
  /** The native id targeted by a button's `commandfor` attribute. */
  id: { type: String, default: undefined },
  /** Framework-native controlled state. Omit for native uncontrolled use. */
  open: { type: Boolean, default: undefined },
  /** Initial state when `open` is not controlled. */
  defaultOpen: { type: Boolean, default: false },
  /** Whether Escape, platform dismissal, and backdrop clicks may close it. */
  dismissible: { type: Boolean, default: true }
})

const emit = defineEmits(['update:open'])
const attrs = useAttrs()
const dialog = ref()
const internalOpen = ref(props.defaultOpen)
const nativeOpen = ref(false)
const isControlled = computed(() => props.open !== undefined)
const desiredOpen = computed(() =>
  isControlled.value ? props.open : internalOpen.value
)
const dialogAttrs = computed(() => {
  const {
    class: _class,
    closedby: _closedby,
    open: _open,
    'data-slot': _dataSlot,
    'data-state': _dataState,
    onBeforetoggle: _onBeforetoggle,
    onBeforeToggle: _onBeforeToggle,
    onCancel: _onCancel,
    onClick: _onClick,
    onClose: _onClose,
    onToggle: _onToggle,
    ...rest
  } = attrs

  return rest
})
const dialogClasses = computed(() =>
  twMerge(
    [
      'm-auto w-[calc(100vw-2rem)] max-w-lg overflow-hidden rounded-lg border border-gray-200 bg-white p-6 text-gray-950 shadow-xl outline-none',
      'backdrop:bg-black/50',
      'dark:border-gray-700 dark:bg-gray-950 dark:text-white'
    ],
    attrs.class
  )
)

let commandRoot
let previousDocumentOverflow = ''
let scrollLocked = false
let fallbackInvoker

function resolveInvoker(source, element) {
  const activeElement =
    typeof document === 'undefined' ? undefined : document.activeElement

  return [source, activeElement].find(
    (candidate) =>
      candidate &&
      candidate !== document.body &&
      candidate !== document.documentElement &&
      candidate !== element &&
      !element.contains(candidate) &&
      candidate.isConnected &&
      typeof candidate.focus === 'function'
  )
}

function callListener(listener, event) {
  for (const callback of Array.isArray(listener) ? listener : [listener]) {
    callback?.(event)
  }
}

function lockScroll() {
  if (scrollLocked || typeof document === 'undefined') return
  previousDocumentOverflow = document.documentElement.style.overflow
  document.documentElement.style.overflow = 'hidden'
  scrollLocked = true
}

function unlockScroll() {
  if (!scrollLocked || typeof document === 'undefined') return
  document.documentElement.style.overflow = previousDocumentOverflow
  scrollLocked = false
}

function observeNativeOpen(nextOpen) {
  const shouldNotify = desiredOpen.value !== nextOpen
  nativeOpen.value = nextOpen

  if (!isControlled.value) internalOpen.value = nextOpen
  if (nextOpen) lockScroll()
  else unlockScroll()
  if (shouldNotify) emit('update:open', nextOpen)
}

function showModal(source) {
  const element = dialog.value
  if (!element || element.open) return

  fallbackInvoker = resolveInvoker(source, element)
  element.showModal()
  observeNativeOpen(true)
}

function close(returnValue) {
  const element = dialog.value
  if (!element?.open) return

  element.close(returnValue)
  observeNativeOpen(false)
}

function requestClose(returnValue) {
  const element = dialog.value
  if (!element?.open) return

  if (typeof element.requestClose === 'function') {
    element.requestClose(returnValue)
    return
  }

  const event = new Event('cancel', { cancelable: true })
  if (element.dispatchEvent(event)) close(returnValue)
}

function handleBeforeToggle(event) {
  callListener(attrs.onBeforetoggle ?? attrs.onBeforeToggle, event)
}

function handleToggle(event) {
  observeNativeOpen(event.newState === 'open' || dialog.value?.open === true)
  callListener(attrs.onToggle, event)
}

function handleCancel(event) {
  if (!props.dismissible) event.preventDefault()
  callListener(attrs.onCancel, event)
}

function handleClose(event) {
  observeNativeOpen(false)

  if (fallbackInvoker?.isConnected) {
    fallbackInvoker.focus({ preventScroll: true })
  }
  fallbackInvoker = undefined
  callListener(attrs.onClose, event)
}

function clickIsOutsideDialog(event) {
  const rect = event.currentTarget.getBoundingClientRect()

  return (
    event.clientX < rect.left ||
    event.clientX > rect.right ||
    event.clientY < rect.top ||
    event.clientY > rect.bottom
  )
}

function handleClick(event) {
  callListener(attrs.onClick, event)

  if (
    event.defaultPrevented ||
    !props.dismissible ||
    'closedBy' in event.currentTarget ||
    event.target !== event.currentTarget ||
    !clickIsOutsideDialog(event)
  ) {
    return
  }

  requestClose()
}

function commandButton(event) {
  return (event.composedPath?.() ?? [event.target]).find(
    (element) =>
      element?.tagName === 'BUTTON' &&
      element.getAttribute('commandfor') === props.id
  )
}

function handleFallbackCommand(event) {
  const button = commandButton(event)
  if (!button || button.matches(':disabled')) return

  const command = button.getAttribute('command')
  if (command === 'show-modal') showModal(button)
  else if (command === 'close') close(button.value)
  else if (command === 'request-close') requestClose(button.value)
}

function supportsInvokerCommands() {
  return (
    typeof HTMLButtonElement !== 'undefined' &&
    'commandForElement' in HTMLButtonElement.prototype
  )
}

async function syncDesiredOpen() {
  await nextTick()
  const element = dialog.value
  if (!element) return

  if (desiredOpen.value && !element.open) showModal()
  else if (!desiredOpen.value && element.open) close()
  else observeNativeOpen(element.open)
}

watch(desiredOpen, syncDesiredOpen, { flush: 'post' })

onMounted(() => {
  commandRoot = dialog.value?.getRootNode?.() ?? document
  if (!supportsInvokerCommands() && props.id) {
    commandRoot.addEventListener('click', handleFallbackCommand)
  }
  syncDesiredOpen()
})

onBeforeUnmount(() => {
  commandRoot?.removeEventListener('click', handleFallbackCommand)
  if (dialog.value?.open) dialog.value.close()
  unlockScroll()
})

defineExpose({ dialog, showModal, close, requestClose })
</script>

<template>
  <dialog
    ref="dialog"
    v-bind="dialogAttrs"
    :id="id"
    :closedby="dismissible ? 'any' : 'none'"
    data-slot="dialog"
    :data-state="nativeOpen ? 'open' : 'closed'"
    :class="dialogClasses"
    @beforetoggle="handleBeforeToggle"
    @toggle="handleToggle"
    @cancel="handleCancel"
    @close="handleClose"
    @click="handleClick"
  >
    <slot />
  </dialog>
</template>
jsx
import { forwardRef, useCallback, useEffect, useRef, useState } from 'react'
import { twMerge } from 'tailwind-merge'

const BASE_CLASSES = [
  'm-auto w-[calc(100vw-2rem)] max-w-lg overflow-hidden rounded-lg border border-gray-200 bg-white p-6 text-gray-950 shadow-xl outline-none',
  'backdrop:bg-black/50',
  'dark:border-gray-700 dark:bg-gray-950 dark:text-white'
]

const Dialog = forwardRef(function Dialog(
  {
    id,
    open: controlledOpen,
    defaultOpen = false,
    dismissible = true,
    onOpenChange,
    className,
    children,
    onBeforeToggle,
    onToggle,
    onCancel,
    onClose,
    onClick,
    ...dialogProps
  },
  forwardedRef
) {
  const dialogRef = useRef(null)
  const fallbackInvoker = useRef()
  const previousDocumentOverflow = useRef('')
  const scrollLocked = useRef(false)
  const [internalOpen, setInternalOpen] = useState(defaultOpen)
  const [nativeOpen, setNativeOpen] = useState(false)
  const isControlled = controlledOpen !== undefined
  const desiredOpen = isControlled ? controlledOpen : internalOpen
  const controlledRef = useRef(isControlled)
  const desiredOpenRef = useRef(desiredOpen)
  const onOpenChangeRef = useRef(onOpenChange)

  controlledRef.current = isControlled
  desiredOpenRef.current = desiredOpen
  onOpenChangeRef.current = onOpenChange

  const resolveInvoker = useCallback((source, element) => {
    const activeElement =
      typeof document === 'undefined' ? undefined : document.activeElement

    return [source, activeElement].find(
      (candidate) =>
        candidate &&
        candidate !== document.body &&
        candidate !== document.documentElement &&
        candidate !== element &&
        !element.contains(candidate) &&
        candidate.isConnected &&
        typeof candidate.focus === 'function'
    )
  }, [])

  const setRef = useCallback(
    (element) => {
      dialogRef.current = element

      if (typeof forwardedRef === 'function') forwardedRef(element)
      else if (forwardedRef) forwardedRef.current = element
    },
    [forwardedRef]
  )

  const lockScroll = useCallback(() => {
    if (scrollLocked.current || typeof document === 'undefined') return
    previousDocumentOverflow.current = document.documentElement.style.overflow
    document.documentElement.style.overflow = 'hidden'
    scrollLocked.current = true
  }, [])

  const unlockScroll = useCallback(() => {
    if (!scrollLocked.current || typeof document === 'undefined') return
    document.documentElement.style.overflow = previousDocumentOverflow.current
    scrollLocked.current = false
  }, [])

  const observeNativeOpen = useCallback(
    (nextOpen) => {
      const shouldNotify = desiredOpenRef.current !== nextOpen
      setNativeOpen(nextOpen)

      if (!controlledRef.current) setInternalOpen(nextOpen)
      if (nextOpen) lockScroll()
      else unlockScroll()
      if (shouldNotify) onOpenChangeRef.current?.(nextOpen)
    },
    [lockScroll, unlockScroll]
  )

  const showModal = useCallback(
    (source) => {
      const element = dialogRef.current
      if (!element || element.open) return

      fallbackInvoker.current = resolveInvoker(source, element)
      element.showModal()
      observeNativeOpen(true)
    },
    [observeNativeOpen, resolveInvoker]
  )

  const close = useCallback(
    (returnValue) => {
      const element = dialogRef.current
      if (!element?.open) return

      if (returnValue === undefined) element.close()
      else element.close(returnValue)
      observeNativeOpen(false)
    },
    [observeNativeOpen]
  )

  const requestClose = useCallback(
    (returnValue) => {
      const element = dialogRef.current
      if (!element?.open) return

      if (typeof element.requestClose === 'function') {
        if (returnValue === undefined) element.requestClose()
        else element.requestClose(returnValue)
        return
      }

      const event = new Event('cancel', { cancelable: true })
      if (element.dispatchEvent(event)) close(returnValue)
    },
    [close]
  )

  useEffect(() => {
    const element = dialogRef.current
    if (!element) return

    if (desiredOpen && !element.open) showModal()
    else if (!desiredOpen && element.open) close()
    else observeNativeOpen(element.open)
  }, [close, desiredOpen, observeNativeOpen, showModal])

  useEffect(() => {
    const element = dialogRef.current
    const root = element?.getRootNode?.() ?? document
    const supportsCommands =
      typeof HTMLButtonElement !== 'undefined' &&
      'commandForElement' in HTMLButtonElement.prototype

    if (supportsCommands || !id) return

    function handleFallbackCommand(event) {
      const button = (event.composedPath?.() ?? [event.target]).find(
        (candidate) =>
          candidate?.tagName === 'BUTTON' &&
          candidate.getAttribute('commandfor') === id
      )
      if (!button || button.matches(':disabled')) return

      const command = button.getAttribute('command')
      if (command === 'show-modal') showModal(button)
      else if (command === 'close') close(button.value)
      else if (command === 'request-close') requestClose(button.value)
    }

    root.addEventListener('click', handleFallbackCommand)
    return () => root.removeEventListener('click', handleFallbackCommand)
  }, [close, id, requestClose, showModal])

  useEffect(
    () => () => {
      if (dialogRef.current?.open) dialogRef.current.close()
      unlockScroll()
    },
    [unlockScroll]
  )

  function handleToggle(event) {
    observeNativeOpen(
      event.nativeEvent?.newState === 'open' || dialogRef.current?.open === true
    )
    onToggle?.(event)
  }

  function handleCancel(event) {
    if (!dismissible) event.preventDefault()
    onCancel?.(event)
  }

  function handleClose(event) {
    observeNativeOpen(false)

    if (fallbackInvoker.current?.isConnected) {
      fallbackInvoker.current.focus({ preventScroll: true })
    }
    fallbackInvoker.current = undefined
    onClose?.(event)
  }

  function handleClick(event) {
    onClick?.(event)

    const rect = event.currentTarget.getBoundingClientRect()
    const outside =
      event.clientX < rect.left ||
      event.clientX > rect.right ||
      event.clientY < rect.top ||
      event.clientY > rect.bottom

    if (
      event.defaultPrevented ||
      !dismissible ||
      'closedBy' in event.currentTarget ||
      event.target !== event.currentTarget ||
      !outside
    ) {
      return
    }

    requestClose()
  }

  return (
    <dialog
      {...dialogProps}
      ref={setRef}
      id={id}
      closedby={dismissible ? 'any' : 'none'}
      data-slot="dialog"
      data-state={nativeOpen ? 'open' : 'closed'}
      className={twMerge(BASE_CLASSES, className)}
      onBeforeToggle={onBeforeToggle}
      onToggle={handleToggle}
      onCancel={handleCancel}
      onClose={handleClose}
      onClick={handleClick}
    >
      {children}
    </dialog>
  )
})

export default Dialog
svelte
<script>
  import { onMount, untrack } from "svelte";
  import { twMerge } from "tailwind-merge";

  const BASE_CLASSES = [
    "m-auto w-[calc(100vw-2rem)] max-w-lg overflow-hidden rounded-lg border border-gray-200 bg-white p-6 text-gray-950 shadow-xl outline-none",
    "backdrop:bg-black/50",
    "dark:border-gray-700 dark:bg-gray-950 dark:text-white",
  ];

  let {
    id,
    open = $bindable(),
    defaultOpen = false,
    dismissible = true,
    onOpenChange,
    class: className = "",
    closedby: _closedby,
    "data-slot": _dataSlot,
    "data-state": _dataState,
    onbeforetoggle,
    ontoggle,
    oncancel,
    onclose,
    onclick,
    children,
    ...dialogProps
  } = $props();

  let internalOpen = $state(untrack(() => defaultOpen));
  let nativeOpen = $state(false);
  let dialogElement = $state();
  let fallbackInvoker;
  let previousDocumentOverflow = "";
  let scrollLocked = false;
  let desiredOpen = $derived(open ?? internalOpen);

  function resolveInvoker(source, element) {
    const activeElement =
      typeof document === "undefined" ? undefined : document.activeElement;

    return [source, activeElement].find(
      (candidate) =>
        candidate &&
        candidate !== document.body &&
        candidate !== document.documentElement &&
        candidate !== element &&
        !element.contains(candidate) &&
        candidate.isConnected &&
        typeof candidate.focus === "function",
    );
  }

  function lockScroll() {
    if (scrollLocked || typeof document === "undefined") return;
    previousDocumentOverflow = document.documentElement.style.overflow;
    document.documentElement.style.overflow = "hidden";
    scrollLocked = true;
  }

  function unlockScroll() {
    if (!scrollLocked || typeof document === "undefined") return;
    document.documentElement.style.overflow = previousDocumentOverflow;
    scrollLocked = false;
  }

  function observeNativeOpen(nextOpen) {
    const shouldNotify = desiredOpen !== nextOpen;
    nativeOpen = nextOpen;

    if (open === undefined) internalOpen = nextOpen;
    else open = nextOpen;
    if (nextOpen) lockScroll();
    else unlockScroll();
    if (shouldNotify) onOpenChange?.(nextOpen);
  }

  export function showModal(source) {
    if (!dialogElement || dialogElement.open) return;

    fallbackInvoker = resolveInvoker(source, dialogElement);
    dialogElement.showModal();
    observeNativeOpen(true);
  }

  export function close(returnValue) {
    if (!dialogElement?.open) return;

    if (returnValue === undefined) dialogElement.close();
    else dialogElement.close(returnValue);
    observeNativeOpen(false);
  }

  export function requestClose(returnValue) {
    if (!dialogElement?.open) return;

    if (typeof dialogElement.requestClose === "function") {
      if (returnValue === undefined) dialogElement.requestClose();
      else dialogElement.requestClose(returnValue);
      return;
    }

    const event = new Event("cancel", { cancelable: true });
    if (dialogElement.dispatchEvent(event)) close(returnValue);
  }

  function handleToggle(event) {
    observeNativeOpen(
      event.newState === "open" || dialogElement?.open === true,
    );
    ontoggle?.(event);
  }

  function handleCancel(event) {
    if (!dismissible) event.preventDefault();
    oncancel?.(event);
  }

  function handleClose(event) {
    observeNativeOpen(false);

    if (fallbackInvoker?.isConnected) {
      fallbackInvoker.focus({ preventScroll: true });
    }
    fallbackInvoker = undefined;
    onclose?.(event);
  }

  function handleClick(event) {
    onclick?.(event);

    const rect = event.currentTarget.getBoundingClientRect();
    const outside =
      event.clientX < rect.left ||
      event.clientX > rect.right ||
      event.clientY < rect.top ||
      event.clientY > rect.bottom;

    if (
      event.defaultPrevented ||
      !dismissible ||
      "closedBy" in event.currentTarget ||
      event.target !== event.currentTarget ||
      !outside
    ) {
      return;
    }

    requestClose();
  }

  function commandButton(event) {
    return (event.composedPath?.() ?? [event.target]).find(
      (element) =>
        element?.tagName === "BUTTON" &&
        element.getAttribute("commandfor") === id,
    );
  }

  $effect(() => {
    const element = dialogElement;
    const shouldOpen = desiredOpen;
    if (!element) return;

    if (shouldOpen && !element.open) showModal();
    else if (!shouldOpen && element.open) close();
    else observeNativeOpen(element.open);
  });

  onMount(() => {
    const root = dialogElement?.getRootNode?.() ?? document;
    const supportsCommands =
      typeof HTMLButtonElement !== "undefined" &&
      "commandForElement" in HTMLButtonElement.prototype;

    function handleFallbackCommand(event) {
      const button = commandButton(event);
      if (!button || button.matches(":disabled")) return;

      const command = button.getAttribute("command");
      if (command === "show-modal") showModal(button);
      else if (command === "close") close(button.value);
      else if (command === "request-close") requestClose(button.value);
    }

    if (!supportsCommands && id) {
      root.addEventListener("click", handleFallbackCommand);
    }

    return () => {
      root.removeEventListener("click", handleFallbackCommand);
      if (dialogElement?.open) dialogElement.close();
      unlockScroll();
    };
  });
</script>

<dialog
  {...dialogProps}
  bind:this={dialogElement}
  {id}
  closedby={dismissible ? "any" : "none"}
  data-slot="dialog"
  data-state={nativeOpen ? "open" : "closed"}
  class={twMerge(BASE_CLASSES, className)}
  onbeforetoggle={(event) => onbeforetoggle?.(event)}
  ontoggle={handleToggle}
  oncancel={handleCancel}
  onclose={handleClose}
  onclick={handleClick}
>
  {@render children?.()}
</dialog>
  • Button — use native commands to open, close, or submit a Dialog.
  • Popover — choose for non-modal content that leaves the page interactive.
  • Menu — choose for a compact list of actions or destinations.
  • Toast — report completion without interrupting the current task.

All open source projects are released under the MIT License.