Dialog
dialoguiFree-form modal primitive — composable from `Dialog`, `DialogTrigger`, `DialogContent`, and friends. Use for forms, info cards, pickers, and any custom modal layout. Hand-rolled runes behavior: portal, Escape/outside close, scroll lock, focus trap, and labelledby wiring.
When to use which
Decision GuideDialogs capture immediate user focus for critical actions. Sheets slide in from screen edges for deep secondary forms, while Popovers provide lightweight contextual cards.
| Component | Role | Use when |
|---|---|---|
| Dialogcurrent Critical confirmations, destructive warnings, or focused single-purpose tasks requiring user resolution. | Modal Window | |
| Sheet Complex edit forms, filter panes, or shopping carts that retain underlying page context. | Off-Canvas Slide-Over | |
| Drawer Mobile bottom sheets, touch-friendly sliding panels, or secondary inspection trays. | Edge Panel / Bottom Sheet | |
| Popover Small contextual dropdowns, color pickers, date pickers, or inline configuration flyouts. | Contextual Floating Card |
Installation
$pnpm dlx shadcn-svelte@latest add https://uipkge.dev/r/svelte/dialog.json$npx shadcn-svelte@latest add https://uipkge.dev/r/svelte/dialog.json$yarn dlx shadcn-svelte@latest add https://uipkge.dev/r/svelte/dialog.json$bunx shadcn-svelte@latest add https://uipkge.dev/r/svelte/dialog.jsonhttps://uipkge.dev/r/svelte/dialog.jsonInstalls to:src/lib/components/ui/dialog/Examples
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
openpass `open` once for an uncontrolled dialog with a custom default. | boolean | — | optional |
modalbody scroll-lock, and autofocus, and the overlay stops intercepting pointer events so the page stays interactive (Radix `modal` parity). | boolean | true | optional |
onOpenChange | (open: boolean) => void | — | optional |
children | Snippet | — | optional |
Schema
Type aliases from this item's source — use them to shape the data you pass in.
DialogContextValueinterface DialogContextValue {
readonly open: boolean
setOpen: (open: boolean) => void
readonly titleId: string
readonly descriptionId: string
/** False renders non-modal: no focus trap, body scroll-lock, or autofocus
* (Radix `modal` parity). The overlay still renders unless the consumer
* hides it — like Radix, non-modal panels typically omit the overlay. */
readonly modal: boolean
}npm dependencies
Includes
Used by
Files installed (13)
src/lib/components/ui/dialog/Dialog.svelte1.3 kB
<script lang="ts" module> import type { Snippet } from 'svelte' export interface DialogProps { /** Controlled open state. The initial value doubles as `defaultOpen` — * pass `open` once for an uncontrolled dialog with a custom default. */ open?: boolean /** When false the dialog is non-modal: content skips the focus trap, * body scroll-lock, and autofocus, and the overlay stops intercepting * pointer events so the page stays interactive (Radix `modal` parity). */ modal?: boolean onOpenChange?: (open: boolean) => void children?: Snippet } let dialogIdCounter = 0 </script> <script lang="ts"> import { setDialogContext } from './context' let { open = $bindable(false), modal = true, onOpenChange, children }: DialogProps = $props() dialogIdCounter += 1 const titleId = `uipkge-dialog-title-${dialogIdCounter}` const descriptionId = `uipkge-dialog-description-${dialogIdCounter}` function setOpen(next: boolean) { if (next === open) return open = next onOpenChange?.(next) } setDialogContext({ get open() { return open }, setOpen, get titleId() { return titleId }, get descriptionId() { return descriptionId }, get modal() { return modal }, }) </script> {@render children?.()}src/lib/components/ui/dialog/DialogClose.svelte1.1 kB
<script lang="ts" module> import type { Snippet } from 'svelte' import type { HTMLButtonAttributes } from 'svelte/elements' export interface DialogCloseProps extends HTMLButtonAttributes { /** Render your own element as the close control instead of a <button>. */ child?: Snippet<[{ props: Record<string, unknown> }]> ref?: HTMLButtonElement | null } </script> <script lang="ts"> import { getDialogContext } from './context' type OnClick = NonNullable<HTMLButtonAttributes['onclick']> let { class: className, type = 'button', child, children, ref = $bindable(null), onclick, ...restProps }: DialogCloseProps = $props() const ctx = getDialogContext() const closeDialog: OnClick = (e) => { onclick?.(e) if (e.defaultPrevented) return ctx.setOpen(false) } const mergedProps = $derived({ 'data-uipkge': '', 'data-slot': 'dialog-close', class: className, type, onclick: closeDialog, ...restProps, }) </script> {#if child} {@render child({ props: mergedProps })} {:else} <button bind:this={ref} {...mergedProps}>{@render children?.()}</button> {/if}src/lib/components/ui/dialog/DialogContent.svelte3.4 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' export interface DialogContentProps extends HTMLAttributes<HTMLDivElement> { showCloseButton?: boolean ref?: HTMLDivElement | null } </script> <script lang="ts"> import { tick } from 'svelte' import { X } from '@lucide/svelte' import { cn } from '$lib/utils' import { getDialogContext } from './context' import { portal } from './portal' import DialogClose from './DialogClose.svelte' import DialogOverlay from './DialogOverlay.svelte' let { class: className, showCloseButton = true, children, ref = $bindable(null), ...restProps }: DialogContentProps = $props() const ctx = getDialogContext() $effect(() => { if (!ctx.open) return const onKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { e.preventDefault() ctx.setOpen(false) return } // Minimal focus trap: cycle Tab within the panel. if (e.key !== 'Tab' || !ref) return const focusables = ref.querySelectorAll<HTMLElement>( 'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])', ) if (!focusables.length) return const first = focusables[0]! const last = focusables[focusables.length - 1]! if (e.shiftKey && document.activeElement === first) { e.preventDefault() last.focus() } else if (!e.shiftKey && document.activeElement === last) { e.preventDefault() first.focus() } } window.addEventListener('keydown', onKeyDown) const prevOverflow = document.body.style.overflow document.body.style.overflow = 'hidden' tick().then(() => ref?.focus({ preventScroll: true })) return () => { window.removeEventListener('keydown', onKeyDown) document.body.style.overflow = prevOverflow } }) </script> {#if ctx.open} <div use:portal> <DialogOverlay /> <div bind:this={ref} data-uipkge data-slot="dialog-content" role="dialog" aria-modal="true" aria-labelledby={ctx.titleId} aria-describedby={ctx.descriptionId} tabindex="-1" data-state="open" class={cn( 'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:ease-emphasized data-[state=open]:blur-in-2 data-[state=closed]:blur-out-2 data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 data-[state=closed]:duration-[var(--dur-exit)] data-[state=open]:duration-200 sm:max-w-lg', className, )} {...restProps} > {@render children?.()} {#if showCloseButton} <DialogClose class="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4" > <X aria-hidden="true" /> <span class="sr-only">Close</span> </DialogClose> {/if} </div> </div> {/if}src/lib/components/ui/dialog/DialogDescription.svelte0.7 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' export interface DialogDescriptionProps extends HTMLAttributes<HTMLParagraphElement> { ref?: HTMLParagraphElement | null } </script> <script lang="ts"> import { cn } from '$lib/utils' import { getDialogContextOrNull } from './context' let { class: className, children, ref = $bindable(null), ...restProps }: DialogDescriptionProps = $props() const ctx = getDialogContextOrNull() </script> <p bind:this={ref} data-uipkge data-slot="dialog-description" id={ctx?.descriptionId} class={cn('text-muted-foreground text-sm', className)} {...restProps} > {@render children?.()} </p>src/lib/components/ui/dialog/DialogFooter.svelte0.9 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' export interface DialogFooterProps extends HTMLAttributes<HTMLDivElement> { showCloseButton?: boolean ref?: HTMLDivElement | null } </script> <script lang="ts"> import { Button } from '$lib/components/ui/button' import { cn } from '$lib/utils' import DialogClose from './DialogClose.svelte' let { class: className, showCloseButton = false, children, ref = $bindable(null), ...restProps }: DialogFooterProps = $props() </script> <div bind:this={ref} data-uipkge data-slot="dialog-footer" class={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)} {...restProps} > {@render children?.()} {#if showCloseButton} <DialogClose> {#snippet child({ props })} <Button variant="outline" {...props}>Close</Button> {/snippet} </DialogClose> {/if} </div>src/lib/components/ui/dialog/DialogHeader.svelte0.6 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' export interface DialogHeaderProps extends HTMLAttributes<HTMLDivElement> { ref?: HTMLDivElement | null } </script> <script lang="ts"> import { cn } from '$lib/utils' let { class: className, children, ref = $bindable(null), ...restProps }: DialogHeaderProps = $props() </script> <div bind:this={ref} data-uipkge data-slot="dialog-header" class={cn('flex flex-col gap-2 text-center sm:text-left', className)} {...restProps} > {@render children?.()} </div>src/lib/components/ui/dialog/DialogOverlay.svelte2.3 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' export interface DialogOverlayProps extends HTMLAttributes<HTMLDivElement> { /** Fired on pointer-down outside the panel, before close. Call * `preventDefault()` to keep the dialog open (Radix parity). */ onPointerDownOutside?: (e: PointerEvent) => void /** Override the `data-slot` value (e.g. `command-dialog-overlay`). Default `'dialog-overlay'`. */ dataSlot?: string ref?: HTMLDivElement | null } </script> <script lang="ts"> import { cn } from '$lib/utils' import { getDialogContext } from './context' import { portal } from './portal' type OnClick = NonNullable<HTMLAttributes<HTMLDivElement>['onclick']> type OnPointerDown = NonNullable<HTMLAttributes<HTMLDivElement>['onpointerdown']> let { class: className, children, dataSlot = 'dialog-overlay', ref = $bindable(null), onclick, onpointerdown, onPointerDownOutside, ...restProps }: DialogOverlayProps = $props() const ctx = getDialogContext() // Set by the pointerdown that precedes a backdrop click; when the outside // handler prevents default, the matching click must not close. let outsidePrevented = false const firePointerDownOutside: OnPointerDown = (e) => { onpointerdown?.(e) onPointerDownOutside?.(e) outsidePrevented = e.defaultPrevented } const closeOnBackdrop: OnClick = (e) => { onclick?.(e) if (e.defaultPrevented || outsidePrevented) { outsidePrevented = false return } outsidePrevented = false ctx.setOpen(false) } </script> {#if ctx.open} <div bind:this={ref} use:portal data-uipkge data-slot={dataSlot} data-state="open" class={cn( 'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:ease-emphasized data-[state=open]:blur-in-2 data-[state=closed]:blur-out-2 data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 bg-foreground/50 fixed inset-0 z-50 data-[state=closed]:duration-[var(--dur-exit)] data-[state=open]:duration-200', !ctx.modal && 'pointer-events-none', className, )} {...restProps} onclick={closeOnBackdrop} onpointerdown={firePointerDownOutside} > {@render children?.()} </div> {/if}src/lib/components/ui/dialog/DialogScrollContent.svelte3.6 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' export interface DialogScrollContentProps extends HTMLAttributes<HTMLDivElement> { ref?: HTMLDivElement | null } </script> <script lang="ts"> import { tick } from 'svelte' import { X } from '@lucide/svelte' import { cn } from '$lib/utils' import { getDialogContext } from './context' import { portal } from './portal' import DialogClose from './DialogClose.svelte' type OnClick = NonNullable<HTMLAttributes<HTMLDivElement>['onclick']> let { class: className, children, ref = $bindable(null), ...restProps }: DialogScrollContentProps = $props() const ctx = getDialogContext() function handleBackdropClick(e: MouseEvent) { // Only backdrop clicks close — clicks inside the panel bubble with a // different target. Scrollbar clicks land on the container itself, so // exclude them like the Vue twin does. if (e.target !== e.currentTarget) return const el = e.currentTarget as HTMLElement if (e.offsetX > el.clientWidth || e.offsetY > el.clientHeight) return ctx.setOpen(false) } const onBackdropClick: OnClick = (e) => handleBackdropClick(e) $effect(() => { if (!ctx.open) return const onKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { e.preventDefault() ctx.setOpen(false) return } if (e.key !== 'Tab' || !ref) return const focusables = ref.querySelectorAll<HTMLElement>( 'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])', ) if (!focusables.length) return const first = focusables[0]! const last = focusables[focusables.length - 1]! if (e.shiftKey && document.activeElement === first) { e.preventDefault() last.focus() } else if (!e.shiftKey && document.activeElement === last) { e.preventDefault() first.focus() } } window.addEventListener('keydown', onKeyDown) const prevOverflow = document.body.style.overflow document.body.style.overflow = 'hidden' tick().then(() => ref?.focus({ preventScroll: true })) return () => { window.removeEventListener('keydown', onKeyDown) document.body.style.overflow = prevOverflow } }) </script> {#if ctx.open} <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions: backdrop click-catcher; keyboard users dismiss with Escape --> <div use:portal data-uipkge data-slot="dialog-overlay" data-state="open" class="data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:ease-emphasized data-[state=open]:blur-in-2 data-[state=closed]:blur-out-2 data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 bg-foreground/50 fixed inset-0 z-50 grid place-items-center overflow-y-auto data-[state=closed]:duration-[var(--dur-exit)] data-[state=open]:duration-200" onclick={onBackdropClick} > <div bind:this={ref} data-uipkge data-slot="dialog-content" role="dialog" aria-modal="true" aria-labelledby={ctx.titleId} aria-describedby={ctx.descriptionId} tabindex="-1" class={cn( 'bg-background relative z-50 my-8 grid w-full max-w-lg gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg md:w-full', className, )} {...restProps} > {@render children?.()} <DialogClose class="hover:bg-secondary absolute top-4 right-4 rounded-md p-0.5 transition-colors duration-200"> <X class="h-4 w-4" aria-hidden="true" /> <span class="sr-only">Close</span> </DialogClose> </div> </div> {/if}src/lib/components/ui/dialog/DialogTitle.svelte0.7 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' export interface DialogTitleProps extends HTMLAttributes<HTMLHeadingElement> { ref?: HTMLHeadingElement | null } </script> <script lang="ts"> import { cn } from '$lib/utils' import { getDialogContextOrNull } from './context' let { class: className, children, ref = $bindable(null), ...restProps }: DialogTitleProps = $props() const ctx = getDialogContextOrNull() </script> <h2 bind:this={ref} data-uipkge data-slot="dialog-title" id={ctx?.titleId} class={cn('text-lg leading-none font-semibold', className)} {...restProps} > {@render children?.()} </h2>src/lib/components/ui/dialog/DialogTrigger.svelte1.1 kB
<script lang="ts" module> import type { Snippet } from 'svelte' import type { HTMLButtonAttributes } from 'svelte/elements' export interface DialogTriggerProps extends HTMLButtonAttributes { /** Render your own element as the trigger instead of a <button>. */ child?: Snippet<[{ props: Record<string, unknown> }]> ref?: HTMLButtonElement | null } </script> <script lang="ts"> import { getDialogContext } from './context' type OnClick = NonNullable<HTMLButtonAttributes['onclick']> let { class: className, type = 'button', child, children, ref = $bindable(null), onclick, ...restProps }: DialogTriggerProps = $props() const ctx = getDialogContext() const openDialog: OnClick = (e) => { onclick?.(e) if (e.defaultPrevented) return ctx.setOpen(true) } const mergedProps = $derived({ 'data-uipkge': '', 'data-slot': 'dialog-trigger', class: className, type, onclick: openDialog, ...restProps, }) </script> {#if child} {@render child({ props: mergedProps })} {:else} <button bind:this={ref} {...mergedProps}>{@render children?.()}</button> {/if}src/lib/components/ui/dialog/context.ts1 kB
import { getContext, setContext } from 'svelte' export interface DialogContextValue { readonly open: boolean setOpen: (open: boolean) => void readonly titleId: string readonly descriptionId: string /** False renders non-modal: no focus trap, body scroll-lock, or autofocus * (Radix `modal` parity). The overlay still renders unless the consumer * hides it — like Radix, non-modal panels typically omit the overlay. */ readonly modal: boolean } const DIALOG_CONTEXT_KEY = Symbol('uipkge-dialog') export function setDialogContext(value: DialogContextValue): void { setContext(DIALOG_CONTEXT_KEY, value) } export function getDialogContext(): DialogContextValue { const value = getContext<DialogContextValue | undefined>(DIALOG_CONTEXT_KEY) if (!value) throw new Error('Dialog parts must be used inside <Dialog>') return value } export function getDialogContextOrNull(): DialogContextValue | null { return getContext<DialogContextValue | null>(DIALOG_CONTEXT_KEY) ?? null }src/lib/components/ui/dialog/portal.ts0.5 kB
import type { Action } from 'svelte/action' /** * Move the node to `document.body` (or a selector/element target) while it is * mounted. Actions only run in the browser, so this is SSR-safe. */ export const portal: Action<HTMLElement, HTMLElement | string | undefined> = (node, target) => { const targetEl = typeof target === 'string' ? document.querySelector(target) : (target ?? document.body) ;(targetEl ?? document.body).appendChild(node) return { destroy() { node.remove() }, } }src/lib/components/ui/dialog/index.ts0.9 kB
export { default as Dialog, type DialogProps } from './Dialog.svelte' export { default as DialogClose, type DialogCloseProps } from './DialogClose.svelte' export { default as DialogContent, type DialogContentProps } from './DialogContent.svelte' export { default as DialogDescription, type DialogDescriptionProps } from './DialogDescription.svelte' export { default as DialogFooter, type DialogFooterProps } from './DialogFooter.svelte' export { default as DialogHeader, type DialogHeaderProps } from './DialogHeader.svelte' export { default as DialogOverlay, type DialogOverlayProps } from './DialogOverlay.svelte' export { default as DialogScrollContent, type DialogScrollContentProps } from './DialogScrollContent.svelte' export { default as DialogTitle, type DialogTitleProps } from './DialogTitle.svelte' export { default as DialogTrigger, type DialogTriggerProps } from './DialogTrigger.svelte'
Raw manifest:https://uipkge.dev/r/svelte/dialog.json