Sheet
A dialog that slides in from an edge of the screen.
Melt UI Dialog
<script lang="ts">
import { Button } from '#lib/components/ui/button/index.js';
import { Input } from '#lib/components/ui/input/index.js';
import { Label } from '#lib/components/ui/label/index.js';
import * as Sheet from '#lib/components/ui/sheet/index.js';
const sides = ['top', 'right', 'bottom', 'left'] as const;
</script>
<div class="row">
{#each sides as side (side)}
<Sheet.Root>
<Sheet.Trigger variant="outline">Open {side}</Sheet.Trigger>
<Sheet.Content {side}>
<Sheet.Header>
<Sheet.Title>Edit profile</Sheet.Title>
<Sheet.Description>Make changes to your profile here.</Sheet.Description>
</Sheet.Header>
<div class="field">
<Label for="sheet-name-{side}">Name</Label>
<Input id="sheet-name-{side}" value="James" />
</div>
<Sheet.Footer>
<Button>Save changes</Button>
<Sheet.Close>Close</Sheet.Close>
</Sheet.Footer>
</Sheet.Content>
</Sheet.Root>
{/each}
</div>
<style>
.row {
display: flex;
flex-wrap: wrap;
gap: var(--dui-space-2);
}
.field {
display: grid;
gap: var(--dui-space-2);
padding-inline: var(--dui-space-4);
}
</style>
Installation
npx distill-ui add sheetIt also copies Button, which Sheet 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 SheetContext = { dialog: Dialog; titleId: string; descriptionId: string };
const key = Symbol('distill-sheet');
export const setSheetContext = (ctx: SheetContext) => setContext(key, ctx);
export const getSheetContext = () => getContext<SheetContext>(key);
import Close from './sheet-close.svelte';
import Content from './sheet-content.svelte';
import Description from './sheet-description.svelte';
import Footer from './sheet-footer.svelte';
import Header from './sheet-header.svelte';
import Title from './sheet-title.svelte';
import Trigger from './sheet-trigger.svelte';
import Root from './sheet.svelte';
export { Root, Trigger, Close, Content, Header, Footer, Title, Description };
<script lang="ts">
import type { HTMLButtonAttributes } from 'svelte/elements';
import { Button, type ButtonSize, type ButtonVariant } from '../button/index.js';
import { getSheetContext } from './context.js';
// A Button that closes the dialog: `<Sheet.Close>Cancel</Sheet.Close>`.
let {
variant = 'outline',
children,
onclick,
...restProps
}: HTMLButtonAttributes & { variant?: ButtonVariant; size?: ButtonSize } = $props();
const { dialog } = getSheetContext();
</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 { getSheetContext } from './context.js';
let {
class: className,
children,
showCloseButton = true,
side = 'right',
...restProps
}: HTMLDialogAttributes & {
children: Snippet;
showCloseButton?: boolean;
side?: 'top' | 'right' | 'bottom' | 'left';
} = $props();
const { dialog, titleId, descriptionId } = getSheetContext();
</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]}
data-side={side}
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 sheet content itself:
--dui-sheet-overlay-bg, --dui-sheet-padding, --dui-sheet-bg, --dui-sheet-fg, --dui-sheet-size
-->
<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-sheet-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 {
position: fixed;
display: flex;
flex-direction: column;
gap: var(--dui-space-4);
width: auto;
max-width: none;
height: auto;
max-height: none;
margin: 0;
padding: var(--dui-sheet-padding, var(--dui-space-6));
border: 0;
background: var(--dui-sheet-bg, var(--dui-color-background));
color: var(--dui-sheet-fg, var(--dui-color-foreground));
box-shadow:
0 0 0 1px color-mix(in oklch, var(--dui-color-foreground) 10%, transparent),
var(--dui-shadow-lg);
font-size: var(--dui-text-sm);
line-height: var(--dui-text-sm-line-height);
outline: none;
transition: translate var(--dui-duration-slow) var(--dui-ease-in-out);
&:not([open]) {
display: none;
}
&::backdrop {
background: transparent;
}
}
/* Each side pins the sheet to that edge and slides it in from off-screen. */
.content[data-side='right'] {
inset: 0 0 0 auto;
width: min(var(--dui-sheet-size, 24rem), 75%);
translate: 100% 0;
}
.content[data-side='left'] {
inset: 0 auto 0 0;
width: min(var(--dui-sheet-size, 24rem), 75%);
translate: -100% 0;
}
.content[data-side='top'] {
inset: 0 0 auto 0;
translate: 0 -100%;
}
.content[data-side='bottom'] {
inset: auto 0 0 0;
translate: 0 100%;
}
/* After the side rules, so it wins when open. */
.content[data-open] {
translate: 0 0;
}
.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 { getSheetContext } from './context.js';
let { class: className, children, ...restProps }: HTMLAttributes<HTMLParagraphElement> = $props();
const { descriptionId } = getSheetContext();
</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;
gap: var(--dui-space-2);
/* Sits at the bottom of the sheet. */
margin-top: auto;
}
</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-1-5);
}
</style>
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { getSheetContext } from './context.js';
let { class: className, children, ...restProps }: HTMLAttributes<HTMLHeadingElement> = $props();
const { titleId } = getSheetContext();
</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 { getSheetContext } from './context.js';
// Renders our Button with Melt's trigger attributes, so it takes the same
// variant and size props: `<Sheet.Trigger variant="outline">Open</Sheet.Trigger>`.
let {
variant = 'default',
children,
...restProps
}: HTMLButtonAttributes & { variant?: ButtonVariant; size?: ButtonSize } = $props();
const { dialog } = getSheetContext();
</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 { setSheetContext } 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)
});
setSheetContext({ dialog, titleId: `${uid}-title`, descriptionId: `${uid}-description` });
</script>
{@render children?.()}
Customizing
Set any of these on the component or on a parent element. See Customizing for how they work.
| Custom property | Default |
|---|---|
--dui-sheet-overlay-bg | var(--dui-color-overlay) |
--dui-sheet-padding | var(--dui-space-6) |
--dui-sheet-bg | var(--dui-color-background) |
--dui-sheet-fg | var(--dui-color-foreground) |
--dui-sheet-size | 24rem |