Alert Dialog
A dialog that interrupts to ask for a decision. Clicking outside does not close it.
Melt UI Dialog
<script lang="ts">
import * as AlertDialog from '#lib/components/ui/alert-dialog/index.js';
</script>
<AlertDialog.Root>
<AlertDialog.Trigger variant="outline">Delete account</AlertDialog.Trigger>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>Are you absolutely sure?</AlertDialog.Title>
<AlertDialog.Description>
This action cannot be undone. This will permanently delete your account.
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
<AlertDialog.Action>Continue</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
Installation
npx distill-ui add alert-dialogIt also copies Button, which Alert 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:
<script lang="ts">
import type { HTMLButtonAttributes } from 'svelte/elements';
import { Button, type ButtonSize, type ButtonVariant } from '../button/index.js';
import { getAlertDialogContext } from './context.js';
// Confirms: runs its onclick, then closes. `<AlertDialog.Action onclick={remove}>`.
let {
variant = 'default',
children,
onclick,
...restProps
}: HTMLButtonAttributes & { variant?: ButtonVariant; size?: ButtonSize } = $props();
const { dialog } = getAlertDialogContext();
</script>
<Button
{...restProps}
{variant}
onclick={(e: MouseEvent & { currentTarget: HTMLButtonElement }) => {
onclick?.(e);
dialog.open = false;
}}
>
{@render children?.()}
</Button>
<script lang="ts">
import type { HTMLButtonAttributes } from 'svelte/elements';
import { Button, type ButtonSize, type ButtonVariant } from '../button/index.js';
import { getAlertDialogContext } from './context.js';
// Closes without doing anything: `<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>`.
let {
variant = 'outline',
children,
onclick,
...restProps
}: HTMLButtonAttributes & { variant?: ButtonVariant; size?: ButtonSize } = $props();
const { dialog } = getAlertDialogContext();
</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 { getAlertDialogContext } from './context.js';
let {
class: className,
children,
...restProps
}: HTMLDialogAttributes & { children: Snippet } = $props();
const { dialog, titleId, descriptionId } = getAlertDialogContext();
</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}
role="alertdialog"
class={['content', className]}
aria-labelledby={titleId}
aria-describedby={descriptionId}
>
{@render children()}
</dialog>
<!--
Options you can set from a parent or on the alert dialog content itself:
--dui-alert-dialog-overlay-bg, --dui-alert-dialog-max-width, --dui-alert-dialog-padding, --dui-alert-dialog-radius, --dui-alert-dialog-bg, --dui-alert-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-alert-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-alert-dialog-max-width, 32rem), calc(100% - var(--dui-space-8)));
padding: var(--dui-alert-dialog-padding, var(--dui-space-6));
border: 0;
border-radius: var(--dui-alert-dialog-radius, var(--dui-radius-xl));
background: var(--dui-alert-dialog-bg, var(--dui-color-popover));
color: var(--dui-alert-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;
}
}
</style>
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { getAlertDialogContext } from './context.js';
let { class: className, children, ...restProps }: HTMLAttributes<HTMLParagraphElement> = $props();
const { descriptionId } = getAlertDialogContext();
</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 { getAlertDialogContext } from './context.js';
let { class: className, children, ...restProps }: HTMLAttributes<HTMLHeadingElement> = $props();
const { titleId } = getAlertDialogContext();
</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 { getAlertDialogContext } from './context.js';
// Renders our Button with Melt's trigger attributes, so it takes the same
// variant and size props: `<AlertDialog.Trigger variant="outline">Open</AlertDialog.Trigger>`.
let {
variant = 'default',
children,
...restProps
}: HTMLButtonAttributes & { variant?: ButtonVariant; size?: ButtonSize } = $props();
const { dialog } = getAlertDialogContext();
</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 { setAlertDialogContext } 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),
// An alert dialog needs an answer, so clicking outside doesn't dismiss it.
closeOnOutsideClick: false
});
setAlertDialogContext({ dialog, titleId: `${uid}-title`, descriptionId: `${uid}-description` });
</script>
{@render children?.()}
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 AlertDialogContext = { dialog: Dialog; titleId: string; descriptionId: string };
const key = Symbol('distill-alert-dialog');
export const setAlertDialogContext = (ctx: AlertDialogContext) => setContext(key, ctx);
export const getAlertDialogContext = () => getContext<AlertDialogContext>(key);
import Action from './alert-dialog-action.svelte';
import Cancel from './alert-dialog-cancel.svelte';
import Content from './alert-dialog-content.svelte';
import Description from './alert-dialog-description.svelte';
import Footer from './alert-dialog-footer.svelte';
import Header from './alert-dialog-header.svelte';
import Title from './alert-dialog-title.svelte';
import Trigger from './alert-dialog-trigger.svelte';
import Root from './alert-dialog.svelte';
export { Root, Trigger, Content, Header, Footer, Title, Description, Action, Cancel };
Customizing
Set any of these on the component or on a parent element. See Customizing for how they work.
| Custom property | Default |
|---|---|
--dui-alert-dialog-overlay-bg | var(--dui-color-overlay) |
--dui-alert-dialog-max-width | 32rem |
--dui-alert-dialog-padding | var(--dui-space-6) |
--dui-alert-dialog-radius | var(--dui-radius-xl) |
--dui-alert-dialog-bg | var(--dui-color-popover) |
--dui-alert-dialog-fg | var(--dui-color-popover-foreground) |