Snap points make the sheet easy to resize.
snap-points="20vh 55vh 90vh" gives the sheet several resting heights. Drag
between them, or flick to jump one.
<sheet-panel snap-points="20vh 55vh 90vh" initial-snap="1">…</sheet-panel> // index, not length — the snap list is ordered as written sheet.activeSnap; // 1 sheet.snapTo(2); // spring up to 90vh sheet.addEventListener('snapchange', ({ detail }) => { console.log(detail); // { from: 1, to: 2 } });
Close a side panel with a swipe.
position="left" or "right" turns the drag horizontal. One fixed
width, and a swipe toward its own edge closes it.
<sheet-panel position="left">…</sheet-panel> <sheet-panel position="right" effect="slide-fade">…</sheet-panel> /* Optional fixed-width override. No width attribute is needed. */ #left-panel > dialog { --sheet-active-size: 24rem; }
Attached to the edge, or a floating card.
mode="card" insets the same sheet from every edge and rounds all four
corners. Same snaps, same gestures — it just floats.
<!-- edge · flush to the bottom, two corners rounded --> <sheet-panel position="bottom" snap-points="45vh 80vh">…</sheet-panel> <!-- inset card · detached on three sides, all four corners --> <sheet-panel position="bottom" mode="card" effect="fade-scale" snap-points="45vh 80vh">…</sheet-panel> /* sheet.css — geometry only, no script reads it */ dialog-panel[morph][state]:has(sheet-panel[data-mode='card'][data-position='bottom']) > dialog { inset: auto var(--sheet-card-margin) var(--sheet-card-margin); /* right · bottom · left */ max-width: calc(100vw - 2 * var(--sheet-card-margin)); border-radius: var(--sheet-card-border-radius); padding-bottom: 0; /* the margin already clears the safe area */ } /* set the gap yourself — 12px reads well down to a 375px viewport */ :root { --sheet-card-margin: 12px; --sheet-card-border-radius: 20px; }
A bottom sheet on a phone. Something else entirely on a desktop.
Below breakpoint the mobile attributes apply; above it, their
desktop-* twins. Resize the window while one is open and it morphs in place.
// bottom sheet on a phone, right-hand drawer that shrinks away on a desktop <sheet-panel snap-points="48vh 92vh" breakpoint="768" desktop-position="right" desktop-mode="card" desktop-exit-effect="fade-scale">…</sheet-panel> // action sheet on a phone, centred modal on a desktop <sheet-panel desktop-position="center">…</sheet-panel>
Or decline to appear at all.
Past max-display-width the sheet refuses to open, and an open one hides. For
when a wide screen wants a different component entirely.
// declines above the width rather than presenting badly <sheet-panel max-display-width="900">…</sheet-panel>
Three ways to animate in.
slide travels in from the edge, slide-fade fades across a short
slide, and fade-scale fades up in place.
<sheet-panel effect="slide-fade" desktop-effect="fade-scale">…</sheet-panel> // sheet-engine.js — the hidden endpoint, per effect if (hidden && effect === 'slide') distance = size + inset + cushion; if (hidden && effect === 'slide-fade') distance = 24, opacity = 0; if (hidden && effect === 'fade-scale') scale = 0.95, opacity = 0;
How a sheet leaves.
exit-effect lets leaving differ from arriving. Drag either card down and it
keeps going the way your finger was moving.
Sometimes a sheet must not be waved away. dismiss="none" closes the swipe,
backdrop and Escape routes — the buttons still work.
<sheet-panel effect="slide" exit-effect="fade-scale">…</sheet-panel> // swipe, backdrop and Escape refused; buttons still work <sheet-panel dismiss="none">…</sheet-panel> // or open just some of them <sheet-panel dismiss="backdrop escape">…</sheet-panel> // sheet-engine.js — an exit never ends closer to rest than it started const floor = away > 0 ? away + cushion : 0;
The bar stays put while the list moves.
Add <sheet-footer> and the header and footer hold still while the
content scrolls between them.
<sheet-panel snap-points="35vh 75vh"> <sheet-header>…</sheet-header> /* rigid · drag surface */ <sheet-content>…</sheet-content> /* flex: 1 · scrolls */ <sheet-footer> <button>Place order</button> </sheet-footer> /* rigid · owns the safe area */ </sheet-panel> /* sheet.css */ sheet-panel[data-position='bottom'] sheet-footer { padding-bottom: calc(var(--sheet-footer-padding) + env(safe-area-inset-bottom, 0px)); }
Bind to events using the API.
Six events, all bubbling — four lifecycle events from
<dialog-panel>, plus snapchange and
snaprelease from the sheet. The log below is wired to every panel on this
page.
Events log
const panel = document.querySelector('#account-panel'); for (const name of ['beforeShow', 'shown', 'beforeHide', 'hidden']) { panel.addEventListener(name, () => console.log(name)); } // bubbles up from the sheet, on commit only panel.addEventListener('snapchange', ({ detail }) => { console.log(`snap ${detail.from} → ${detail.to}`); }); // cancelable — this sheet refuses to open panel.addEventListener('beforeShow', (event) => event.preventDefault());
Try it out for yourself.
Presets in force — no spring attribute set.
Fourteen attributes and twenty-six dials.
Five elements, fourteen attributes, twenty-six style tokens, three methods, six events. Everything else is HTML you write yourself.
import '@magic-spells/sheet'; // registers dialog-panel too import '@magic-spells/dialog-panel/css'; import '@magic-spells/sheet/css'; const sheet = document.querySelector('sheet-panel'); sheet.show(trigger); // always open through show(), never dialog.showModal() sheet.snapTo(1); sheet.hide();
Elements
<dialog-panel><sheet-panel><sheet-header><sheet-content><sheet-footer>Attributes
snap-points85vhpx, vh, dvh,
svh, lvh, vw, rem, %.
Mobile bottom sheets only: ignored by left, right, and center
positions, and ignored entirely at or above breakpoint. Resolved at open
and on resize.
initial-snappositionbottombottom, left, right, or
center. Center ignores snap-points and mode,
takes its width from --sheet-center-width, and takes its height from its
own content.
modeedgeedge welds it to one side, card insets it
on all four.
effectslideslide, fade-scale, or
slide-fade.
exit-effecteffecteffect.breakpoint768beforeHide or hidden.
desktop-positioncenter for a bottom mobile position, else that position
center rather than inheriting itself, because a floating card in the
middle of the screen is what a desktop dialog reads as — say
desktop-position="bottom" to keep the bottom sheet, which then sizes to
its content and stays anchored to the bottom edge, capped by
calc(100dvh - 2 * var(--sheet-card-margin)). Center ignores
snap-points and mode, takes its width from
--sheet-center-width, and takes its height from its own content.
desktop-modecardedge or card. It does not inherit
mode — absent, it is always card — and a
center profile ignores it, having no edge to sit flush against.
desktop-effectfade-scale for center, else effectdesktop-exit-effectexit-effect, else desktop-effectexit-effect to desktop-effect.
dismissswipe, backdrop, and escape.
Absent opens all three; none or an empty attribute locks all three; any
subset opens exactly those routes. Buttons and programmatic hide() still
work.
max-display-widthshow() will open the sheet.
spring"attraction friction", both exclusive of
0 and 1; invalid values are ignored. Governs how the sheet
arrives; exits and snaps keep their presets. Also settable as a property:
sheet.spring = { attraction, friction }.
CSS Custom Properties
--sheet-active-sizemin(26rem, 90vw) on sidescenter, or a desktop bottom.
--sheet-panel-backgroundwhite--sheet-panel-border-radius25px--sheet-card-margin12px--sheet-card-border-radius20px--sheet-panel-box-shadow--sheet-handle-color#bbb--sheet-handle-width50px--sheet-handle-height5px--sheet-handle-side-length--sheet-handle-width--sheet-handle-side-thickness4px--sheet-handle-offset8px--sheet-bleed120px--sheet-content-padding20px--sheet-footer-padding--sheet-content-padding--sheet-footer-backgroundtransparent--sheet-overlay-backgroundrgba(0, 0, 0, 0.5)<dialog-backdrop>.--sheet-overlay-blur5px--sheet-desktop-panel-widthmin(26rem, 90vw)--sheet-center-widthmin(28rem, 100vw - 2 * card margin)center dialog; its height follows its content.--sheet-exit-cushion28px--sheet-morph-duration600ms0 swaps a
profile instantly and skips the blob.
--sheet-morph-easingcubic-bezier(0.34, 1.32, 0.52, 1)--sheet-blob-z-index1002--sheet-backdrop-progress0–1.
--sheet-progress1.024; no other profile exceeds 1.
Methods & Properties
show(triggerEl)hide()snapTo(index)activeSnapdismissPolicy{ swipe, backdrop, escape } booleans parsed from
dismiss.
panel<dialog-panel>.dialog<dialog> — the surface the engine animates.header<sheet-header>.content<sheet-content>.footer<sheet-footer>, when present.
The fourteen attributes above reflect through their camelCase properties —
snapPoints, initialSnap, position,
mode, effect, exitEffect, breakpoint,
desktopPosition, desktopMode, desktopEffect,
desktopExitEffect, dismiss, maxDisplayWidth, and
spring.
Events
beforeShowshownbeforeHidehiddensnapchange{ from, to } as zero-based indices. Never mid-drag.
snaprelease{ velocity, flick, direction, size, target, prevented }; target is a
zero-based snap index or null for dismissal. When dismiss
refuses a gesture dismissal, the sheet redirects to the active snap, reports it as
target, and sets prevented: true.
The first four are dispatched by the parent panel and carry state,
triggerElement and result in their detail. The last two are
dispatched by the <sheet-panel> itself. All six bubble and are
composed.
Accessibility
Modal semantics, focus trapping, focus return, Escape handling and body scroll lock are
delegated to the native <dialog> through the parent panel — the sheet
adds no ARIA of its own. Give every dialog an accessible name with
aria-labelledby or aria-label, label icon-only buttons, keep a
visible close control, and pass the real opener to show(). Any element with
data-action-hide-dialog closes the sheet.
Never make a drag the only route to an action. Interactive descendants stay clickable
because pointer capture is deferred until movement passes 5px. The scrim is
not a drag surface at all — showModal() makes the native
::backdrop win every hit test out there, so
<dialog-backdrop> only paints. A click on it dismisses when the
pointerdown landed on the dialog itself and the click falls outside the
dialog's rect: no distance or duration gate, which is what keeps a near-miss swipe at the
sheet's edge from being refused.
Browser support
Modern browsers with custom elements, native <dialog>, Pointer Events,
:has(), custom properties and dvh. Pointer Events mean mouse,
pen and touch all take the same path, so every drag on this page works with a mouse.