Skip to content

BottomSheet

A bottom sheet slides up from the bottom of the screen and settles on one of several snap points. Drag the handle (or the content while it’s at the top of its scroll) to resize it, flick it down to dismiss, or tap the handle to toggle between sizes. It opens to its largest snap point by default.

import { BottomSheet } from '@delightstack/components';

Snap points are fractions of the viewport height (a value <= 1) or absolute pixels (a value > 1). The sheet opens to the largest one by default.

View code
<script>
import { BottomSheet, Button } from '@delightstack/components';
let open = $state(false);
</script>
<Button onclick={() => open = true}>Open Bottom Sheet</Button>
<BottomSheet bind:open snap_points={[0.5, 0.92]}>
<div style="padding: 0 1.5rem 1.5rem;">
<h3>Bottom Sheet</h3>
<p>Drag the handle to resize, flick down to dismiss, or tap the handle to toggle.</p>
<Button outline onclick={() => open = false}>Close</Button>
</div>
</BottomSheet>

Define any number of snap heights. The user drags between them, and you can read or set the active one with bind:snap.

View code
<script>
import { BottomSheet, Button } from '@delightstack/components';
let open = $state(false);
let snap = $state(0);
</script>
<Button onclick={() => open = true}>Open Multi-Snap Sheet</Button>
<BottomSheet bind:open bind:snap snap_points={[0.3, 0.6, 1]}>
<div style="padding: 0 1.5rem 1.5rem;">
<h3>Drag Me!</h3>
<p>Three snap points at 30%, 60%, and 100%.</p>
<p>Current snap index: {snap}</p>
<Button size="0" outline onclick={() => snap = 0}>30%</Button>
<Button size="0" outline onclick={() => snap = 1}>60%</Button>
<Button size="0" outline onclick={() => snap = 2}>100%</Button>
</div>
</BottomSheet>

As the sheet moves between its collapsed and expanded states, morph_percent runs from 0 to 1. The sheet also exposes it as a --morph-percent CSS variable on its root and passes it into the header and default snippets — so a header can fluidly morph between two layouts (here an avatar shrinks and the title slides up beside it).

By default the morph spans the gap between the two smallest snap points; set morph_range to control exactly where it starts and finishes. Read the value with bind:morph_percent, the onmorph callback, the snippet parameter, or the CSS variable.

View code
<script>
import { BottomSheet, Button } from '@delightstack/components';
let open = $state(false);
let morph_percent = $state(0);
</script>
<Button onclick={() => open = true}>Open Morphing Sheet</Button>
<BottomSheet
bind:open
bind:morph_percent
snap_points={[0.32, 0.92]}
morph_range={[0.32, 0.55]}>
{#snippet header(morph)}
<div class="morph-header">
<!-- interpolate any styles with var(--morph-percent) or the morph param -->
<div class="avatar">🦊</div>
<div class="title">Reynard Fox</div>
<div class="hint">{Math.round(morph * 100)}%</div>
</div>
{/snippet}
<div style="padding: 0.5rem 1.5rem 1.5rem;">
<p>morph_percent is {morph_percent.toFixed(2)}</p>
<Button outline onclick={() => open = false}>Close</Button>
</div>
</BottomSheet>
<style>
.morph-header { --m: var(--morph-percent, 0); position: relative; }
.avatar {
position: absolute; left: 16px; top: calc(8px + 14px * var(--m));
width: calc(40px + 48px * var(--m)); height: calc(40px + 48px * var(--m));
border-radius: 9999px; background: linear-gradient(135deg, #f97316, #db2777);
}
.title {
position: absolute; white-space: nowrap; font-weight: 600;
left: calc(68px * (1 - var(--m)) + 16px * var(--m));
top: calc(18px * (1 - var(--m)) + 116px * var(--m));
font-size: calc(1.05rem + 0.45rem * var(--m));
}
</style>

The header snippet renders a fixed area above the scrollable content and doubles as a drag area.

View code
<script>
import { BottomSheet, Button } from '@delightstack/components';
let open = $state(false);
</script>
<Button onclick={() => open = true}>Open Sheet with Header</Button>
<BottomSheet bind:open snap_points={[0.5, 0.92]}>
{#snippet header()}
<div style="padding: 0 1rem 0.75rem; border-bottom: 1px solid var(--color-border);">
<h3 style="margin: 0;">Filters</h3>
</div>
{/snippet}
<div style="padding: 1rem;">
<p>Scrollable filter content. It scrolls once the sheet is fully expanded.</p>
<Button outline onclick={() => open = false}>Close</Button>
</div>
</BottomSheet>

With dismissible={false}, tapping the backdrop and pressing Escape can’t close the sheet, and dragging is clamped at the smallest snap point — the sheet never travels below it. With multiple snap points you can still drag up to expand (and back down to the smallest size); with a single snap point the drag handle is hidden entirely.

View code
<script>
import { BottomSheet, Button } from '@delightstack/components';
let open = $state(false);
</script>
<Button onclick={() => open = true}>Open Non-Dismissible Sheet</Button>
<BottomSheet bind:open dismissible={false} snap_points={[0.4, 0.85]}>
<div style="padding: 0 1.5rem 1.5rem;">
<h3>Required Action</h3>
<p>You must complete this action before the sheet will close.</p>
<Button onclick={() => open = false}>Done</Button>
</div>
</BottomSheet>

backdrop={false} removes the blur and tint, leaving the page behind clearly visible. The transparent backdrop still catches taps to dismiss the sheet.

View code
<BottomSheet bind:open backdrop={false} blocking={false} snap_points={[0.5, 0.9]}>
<div style="padding: 0 1.5rem 1.5rem;">
<p>No blur or tint — the page behind stays visible.</p>
</div>
</BottomSheet>
PropTypeDefaultDescription
openbooleanfalseControls visibility ($bindable)
snap_pointsnumber[][0.5, 1]Snap heights as viewport fractions (<= 1) or absolute pixels (> 1)
default_snapnumberlargest snapIndex of the snap point to open to
snapnumber0Current snap point index ($bindable)
morph_percentnumber0Morph progress 0–1 between collapsed and expanded ($bindable)
morph_range[number, number]first two snapsHeight range over which morph_percent animates 0→1 (fractions or pixels)
dismissiblebooleantrueWhether dragging down, the backdrop, or Escape can dismiss the sheet
backdropbooleantrueRender the frosted (blur + tint) backdrop; when false it stays transparent but still taps to dismiss
blockingbooleantrueLock body scroll while open
max_widthnumber500Maximum sheet width in pixels (stays centered when wider)
idstringautoElement ID
classstring''Additional CSS classes
childrenSnippet<[number]>undefinedScrollable content; receives the current morph percent
headerSnippet<[number]>undefinedFixed, draggable header above the content; receives the current morph percent
EventDetailDescription
onopennoneFires when the sheet opens
onclosenoneFires after the sheet finishes closing
onsnap{ index: number, height: number }Fires when the sheet settles on a snap
onmorphnumber (0–1)Fires whenever the morph percent changes

The sheet sets these on its root element (handy for morphing headers and custom styling):

VariableDescription
--morph-percentCurrent morph progress (0–1)
--offsetPixels of the sheet revealed from the bottom
--max-offsetMaximum revealable height (the content/viewport cap)
--bottom-sheet-backdropBackdrop tint color (default a translucent black)
--bottom-sheet-blurBackdrop blur radius (default 12px)
  • role="dialog" with aria-modal="true" on the sheet panel
  • Escape dismisses the sheet (when dismissible is true)
  • Backdrop tap dismisses the sheet (when dismissible is true)
  • Body scroll is locked when blocking is true
  • Spring-physics animation with rubber banding past the top snap and velocity-based flick dismissal
  • Scroll-aware drag: the content scrolls at the top snap and seamlessly hands off to a sheet drag when pulled down from its scroll top
  • Respects prefers-reduced-motion (snaps instantly instead of animating)