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.
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.
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
<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
;<>
<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>
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
| Purpose | Vue | React | Svelte |
|---|---|---|---|
| Native target | id | id | id |
| Open state | v-model:open | open, onOpenChange | bind:open |
| Initial state | default-open | defaultOpen | defaultOpen |
| Ambient dismissal | dismissible | dismissible | dismissible |
| Styling | class | className | class |
| Native capability | component ref | HTMLDialogElement ref | component 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.
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 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
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
<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>