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.
<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>;<>
<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>
</><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
<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.
npx klean-ui add dialogWhen 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
| 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 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.
View source 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>
Complete framework source
<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>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<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>