Skip to content

Dialog

Dialog is one native <dialog>, not a family of trigger, portal, overlay, content, title, description, and action components. A real Button targets its id with the platform's command="show-modal" relationship. The application writes the truthful heading, description, form, actions, and Tailwind classes.

Dialog.vue

Installation

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

Run one command from a Boring Stack application. Klean detects the framework and conventional destination, then adds the framework-native source and its direct dependencies.

Terminal
npx klean-ui add dialog

  • No initializer or configuration file
  • No framework, alias, or theme questions
  • No Klean runtime dependency

There is no provider, portal, focus-trap package, trigger component, configuration file, or Klean runtime.

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

Vue

DeleteProject.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>

React

DeleteProject.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

DeleteProject.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>

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, 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, containment, and return.
  • 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

Hagfish and Slipway share the native modal contract while keeping their product language in visible Tailwind classes. The heading, consequence, safest initial action, and completion value remain ordinary application markup.

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

Complete framework source

Vue

Dialog.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 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 = source
  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>

React

Dialog.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 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 = source
      element.showModal()
      observeNativeOpen(true)
    },
    [observeNativeOpen]
  )

  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

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 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 = source;
    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.