Dialog
A window on top of the page. Focus stays inside until it closes.
Melt UI Dialog
<script lang="ts">
import { Button } from '#lib/components/ui/button/index.js';
import * as Dialog from '#lib/components/ui/dialog/index.js';
</script>
<Dialog.Root>
<Dialog.Trigger variant="outline">Edit profile</Dialog.Trigger>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Edit profile</Dialog.Title>
<Dialog.Description>
Make changes to your profile here. Click save when you're done.
</Dialog.Description>
</Dialog.Header>
<p>Dialog body content goes here.</p>
<Dialog.Footer>
<Dialog.Close>Cancel</Dialog.Close>
<Button>Save changes</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
Installation
npx distill-ui add dialogIt also copies Button, which Dialog uses.
It installs @floating-ui/domandmelt if you don't have them yet.
- Set up the tokens once, if you haven't: see manual installation.
- Install the packages it needs:
npm install @floating-ui/dom@^1.6.0 melt@0.44.0 - Copy Button too, which it uses.
- Copy these files into your project:
import { getContext, setContext } from 'svelte';
import type { Dialog } from 'melt/builders';
// Melt gives one builder object per dialog; the parts share it through context.
// Melt doesn't wire up aria-labelledby / aria-describedby, so we carry the ids too.
type DialogContext = { dialog: Dialog; titleId: string; descriptionId: string };
const key = Symbol('distill-dialog');
export const setDialogContext = (ctx: DialogContext) => setContext(key, ctx);
export const getDialogContext = () => getContext<DialogContext>(key);
<script lang="ts">
import type { HTMLButtonAttributes } from 'svelte/elements';
import { Button, type ButtonSize, type ButtonVariant } from '../button/index.js';
import { getDialogContext } from './context.js';
// A Button that closes the dialog: `<Dialog.Close>Cancel</Dialog.Close>`.
let {
variant = 'outline',
children,
onclick,
...restProps
}: HTMLButtonAttributes & { variant?: ButtonVariant; size?: ButtonSize } = $props();
const { dialog } = getDialogContext();
</script>
<Button
{...restProps}
{variant}
onclick={(e: MouseEvent & { currentTarget: HTMLButtonElement }) => {
onclick?.(e);
dialog.open = false;
}}
>
{@render children?.()}
</Button>
<script lang="ts">
import type { Snippet } from 'svelte';
import type { HTMLDialogAttributes } from 'svelte/elements';
import { getDialogContext } from './context.js';
let {
class: className,
children,
showCloseButton = true,
...restProps
}: HTMLDialogAttributes & { children: Snippet; showCloseButton?: boolean } = $props();
const { dialog, titleId, descriptionId } = getDialogContext();
</script>
<!-- Melt uses a native <dialog> in the top layer, so no portal is needed. -->
<div {...dialog.overlay} class="overlay"></div>
<dialog
{...restProps}
{...dialog.content}
class={['content', className]}
aria-labelledby={titleId}
aria-describedby={descriptionId}
>
{@render children()}
{#if showCloseButton}
<button type="button" class="close" onclick={() => (dialog.open = false)}>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
<span class="sr-only">Close</span>
</button>
{/if}
</dialog>
<!--
Options you can set from a parent or on the dialog content itself:
--dui-dialog-overlay-bg, --dui-dialog-max-width, --dui-dialog-padding, --dui-dialog-radius, --dui-dialog-bg, --dui-dialog-fg
-->
<style>
/* Melt closes the dialog on transitionend, so this animates with
transitions on [data-open] rather than the shared keyframes. */
.overlay {
position: fixed;
inset: 0;
width: auto;
height: auto;
margin: 0;
padding: 0;
border: 0;
background: var(--dui-dialog-overlay-bg, var(--dui-color-overlay));
backdrop-filter: blur(4px);
opacity: 0;
transition: opacity var(--dui-duration-fast) var(--dui-ease-out);
&[data-open] {
opacity: 1;
}
}
.content {
display: grid;
gap: var(--dui-space-6);
width: 100%;
max-width: min(var(--dui-dialog-max-width, 28rem), calc(100% - var(--dui-space-8)));
padding: var(--dui-dialog-padding, var(--dui-space-6));
border: 0;
border-radius: var(--dui-dialog-radius, var(--dui-radius-xl));
background: var(--dui-dialog-bg, var(--dui-color-popover));
color: var(--dui-dialog-fg, var(--dui-color-popover-foreground));
box-shadow: 0 0 0 1px color-mix(in oklch, var(--dui-color-foreground) 10%, transparent);
font-size: var(--dui-text-sm);
line-height: var(--dui-text-sm-line-height);
outline: none;
opacity: 0;
scale: 0.95;
transition:
opacity var(--dui-duration-fast) var(--dui-ease-out),
scale var(--dui-duration-fast) var(--dui-ease-out);
&:not([open]) {
display: none;
}
&[data-open] {
opacity: 1;
scale: 1;
}
&::backdrop {
background: transparent;
}
}
.close {
position: absolute;
top: var(--dui-space-4);
right: var(--dui-space-4);
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
border: none;
border-radius: var(--dui-radius-md);
background: transparent;
color: var(--dui-color-foreground);
cursor: pointer;
opacity: 0.7;
transition: opacity var(--dui-duration-fast) var(--dui-ease-out);
&:hover {
opacity: 1;
background: var(--dui-color-muted);
}
&:focus-visible {
outline: none;
box-shadow: 0 0 0 var(--dui-ring-width)
color-mix(in oklch, var(--dui-color-ring) 50%, transparent);
}
svg {
width: 1rem;
height: 1rem;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
</style>
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { getDialogContext } from './context.js';
let { class: className, children, ...restProps }: HTMLAttributes<HTMLParagraphElement> = $props();
const { descriptionId } = getDialogContext();
</script>
<p id={descriptionId} class={['description', className]} {...restProps}>{@render children?.()}</p>
<style>
.description {
margin: 0;
color: var(--dui-color-muted-foreground);
font-size: var(--dui-text-sm);
line-height: var(--dui-text-sm-line-height);
}
</style>
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: HTMLAttributes<HTMLDivElement> & { ref?: HTMLDivElement | null } = $props();
</script>
<div bind:this={ref} class={['footer', className]} {...restProps}>
{@render children?.()}
</div>
<style>
.footer {
display: flex;
flex-direction: column-reverse;
gap: var(--dui-space-2);
@media (width >= 40rem) {
flex-direction: row;
justify-content: flex-end;
}
}
</style>
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: HTMLAttributes<HTMLDivElement> & { ref?: HTMLDivElement | null } = $props();
</script>
<div bind:this={ref} class={['header', className]} {...restProps}>
{@render children?.()}
</div>
<style>
.header {
display: flex;
flex-direction: column;
gap: var(--dui-space-2);
}
</style>
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { getDialogContext } from './context.js';
let { class: className, children, ...restProps }: HTMLAttributes<HTMLHeadingElement> = $props();
const { titleId } = getDialogContext();
</script>
<h2 id={titleId} class={['title', className]} {...restProps}>{@render children?.()}</h2>
<style>
.title {
margin: 0;
font-size: var(--dui-text-base);
line-height: 1;
font-weight: var(--dui-font-weight-medium);
}
</style>
<script lang="ts">
import type { HTMLButtonAttributes } from 'svelte/elements';
import { Button, type ButtonSize, type ButtonVariant } from '../button/index.js';
import { getDialogContext } from './context.js';
// Renders our Button with Melt's trigger attributes, so it takes the same
// variant and size props: `<Dialog.Trigger variant="outline">Open</Dialog.Trigger>`.
let {
variant = 'default',
children,
...restProps
}: HTMLButtonAttributes & { variant?: ButtonVariant; size?: ButtonSize } = $props();
const { dialog } = getDialogContext();
</script>
<Button {...restProps} {...dialog.trigger} aria-haspopup="dialog" {variant}>
{@render children?.()}
</Button>
<script lang="ts">
import { Dialog } from 'melt/builders';
import type { Snippet } from 'svelte';
import { setDialogContext } from './context.js';
let { open = $bindable(false), children }: { open?: boolean; children?: Snippet } = $props();
const uid = $props.id();
const dialog = new Dialog({
open: () => open,
onOpenChange: (v) => (open = v)
});
setDialogContext({ dialog, titleId: `${uid}-title`, descriptionId: `${uid}-description` });
</script>
{@render children?.()}
import Close from './dialog-close.svelte';
import Content from './dialog-content.svelte';
import Description from './dialog-description.svelte';
import Footer from './dialog-footer.svelte';
import Header from './dialog-header.svelte';
import Title from './dialog-title.svelte';
import Trigger from './dialog-trigger.svelte';
import Root from './dialog.svelte';
export { Root, Trigger, Close, Content, Header, Footer, Title, Description };
Customizing
Set any of these on the component or on a parent element. See Customizing for how they work.
| Custom property | Default |
|---|---|
--dui-dialog-overlay-bg | var(--dui-color-overlay) |
--dui-dialog-max-width | 28rem |
--dui-dialog-padding | var(--dui-space-6) |
--dui-dialog-radius | var(--dui-radius-xl) |
--dui-dialog-bg | var(--dui-color-popover) |
--dui-dialog-fg | var(--dui-color-popover-foreground) |