The markup.
<dialog-panel> owns the modal layer, the native
<dialog> is the surface that slides, and
<bottom-sheet> turns pointer gestures into movement. The header draws
the handle and is always draggable; the content flexes and scrolls on its own.
<dialog-panel id="sheet-panel"> <dialog aria-labelledby="sheet-title"> <bottom-sheet> <bottom-sheet-header> <h2 id="sheet-title">A useful title</h2> <button data-action-hide-dialog aria-label="Close">×</button> </bottom-sheet-header> <bottom-sheet-content>…</bottom-sheet-content> </bottom-sheet> </dialog> </dialog-panel> // the trigger is passed in so focus returns to it on close sheet.show(trigger)
Scrolling hands off to dragging.
A downward drag on a list could mean scroll it, or move the panel. The sheet re-asks on every move rather than deciding once — scroll to the top, keep pulling, and the panel takes the gesture without a jump.
// re-asked on every move until it succeeds if (moveY > 0) return content.scrollTop === 0; // hand off at the top // upward: below the tallest snap, growing beats scrolling return snaps.length > 0 && activeSnap < snaps.at(-1); // on the move that wins, remember where it started drag.claimOffset = deltaY; const travel = deltaY - drag.claimOffset;
Snap points.
Give the sheet snap-points="40,70,100" and each number becomes a resting
height as a percentage of the viewport. A flick steps exactly one snap; anything slower
lands on the nearest. Dragging below the shortest snap dismisses the sheet.
<bottom-sheet snap-points="40,70,100">…</bottom-sheet> // the current snap reflects back, on commit only sheet.snap // 70 sheet.snapPoints // [40, 70, 90] sheet.snapTo(90); sheet.addEventListener('snapChange', (e) => { console.log(e.detail); // { from: 70, to: 90 } });
A map-style sheet.
The shape every maps app converges on: opens at a 25dvh peek, a search field in the fixed header, a results list below. Dragging up grows the sheet first — scrolling takes over at the tallest snap.
<bottom-sheet snap-points="25,55,92" snap="25"> <bottom-sheet-header>…</bottom-sheet-header> /* search field */ <bottom-sheet-content>…</bottom-sheet-content> /* results */ </bottom-sheet> // snap= sets the opening height; it reflects after that
A pinned footer.
Add <bottom-sheet-footer> and only the content between the fixed
header and footer scrolls. The footer takes over the safe-area padding so its background
runs under the home indicator — and it's a drag surface too.
<bottom-sheet> <bottom-sheet-header>…</bottom-sheet-header> /* fixed · drag surface */ <bottom-sheet-content>…</bottom-sheet-content> /* flex: 1 · scrolls */ <bottom-sheet-footer> <button>Checkout</button> </bottom-sheet-footer> /* fixed · owns the safe area */ </bottom-sheet> /* bottom-sheet.css */ bottom-sheet-footer { padding: var(--bs-footer-padding, var(--bs-content-padding)); padding-bottom: calc(var(--bs-footer-padding, var(--bs-content-padding)) + env(safe-area-inset-bottom, 0px)); }
A floating, inset sheet.
Add the inset attribute and the sheet detaches from the screen edges — all
four corners rounded, a gap on three sides. It's pure CSS:
:has(bottom-sheet[inset]) does the whole thing and no JavaScript runs.
<bottom-sheet inset>…</bottom-sheet> dialog-panel:has(bottom-sheet[inset]) > dialog { left: var(--bs-panel-inset-x); right: var(--bs-panel-inset-x); width: auto; border-radius: var(--bs-panel-border-radius); margin-bottom: calc(var(--bs-panel-inset-bottom) + env(safe-area-inset-bottom, 0px)); } /* or it peeks by exactly the inset when hidden */ transform: translate3d(0, calc(100% + var(--bs-panel-inset-bottom) + env(safe-area-inset-bottom, 0px)), 0);
Inset + snap points.
The combination most likely to break, so it gets its own sheet. Two snaps, no middle ground — it peeks at 40 or takes the screen at 97. The tall snap stays under 100 so a detached sheet's top corners don't round off screen.
<bottom-sheet inset snap-points="40,97">…</bottom-sheet>
A maximum display width.
Set max-display-width="768" and the sheet only opens below 768px — and
closes itself the moment the viewport widens past the limit. Narrow this window, open it,
then widen the window and watch it leave.
<bottom-sheet max-display-width="768">…</bottom-sheet> // or from script — Infinity removes the limit sheet.maxDisplayWidth = 768; sheet.maxDisplayWidth = Infinity;
Theming with custom properties.
The sliders write --bs-* custom properties onto the
<dialog-panel> — that's the entire theming API. The switches toggle
attributes on the sheet itself. Set the dials first: an open modal makes everything
behind it inert.
// what every control on this page actually does panel.style.setProperty('--bs-panel-border-radius', '25px'); /* or, far more likely, in your own stylesheet */ #my-panel { --bs-panel-background: #171012; --bs-handle-color: #9a6a5a; }
Lifecycle events.
The parent panel fires beforeShow, shown,
beforeHide, and hidden; the first and third are cancelable.
Each carries detail.state, mirrored as a state attribute that
drives the CSS. Open the sheet and close it to watch the sequence fill in.
No events yet.
const panel = document.querySelector('#sheet-panel'); for (const name of ['beforeShow', 'shown', 'beforeHide', 'hidden']) { panel.addEventListener(name, (event) => { console.log(name, event.detail.state); }); } // cancelable — this sheet refuses to open panel.addEventListener('beforeShow', (event) => event.preventDefault());
The full reference.
Four elements, one attribute, fourteen style tokens, two methods, and the panel's four lifecycle events. Everything else is ordinary HTML you write yourself.
import '@magic-spells/dialog-panel'; import '@magic-spells/bottom-sheet'; import '@magic-spells/dialog-panel/css'; import '@magic-spells/bottom-sheet/css'; const sheet = document.querySelector('bottom-sheet'); sheet.show(trigger); sheet.hide();
Elements
<bottom-sheet><bottom-sheet-header><bottom-sheet-content><bottom-sheet-footer>Attributes
max-display-widthmaxDisplayWidth property. Also closes an open sheet on resize past the
limit.
snap-points"40,70,100". Sorted and deduped; values outside 0–100 and anything
unparseable are dropped. Absent or empty leaves the sheet in its two-state mode.
Reflects the snapPoints property.
snapinsetCSS Custom Properties
--bs-panel-backgroundwhite--bs-panel-max-height85vhsnap-points is set — the tallest snap
becomes the cap.
--bs-panel-border-radius25pxinset is set.
--bs-panel-bleed60pxbox-shadow, so
overflow: hidden can't clip it, it costs no layout, and it travels with
the drag transform. Dropped for inset, where the gap is the point.
--bs-panel-inset-x12pxinset only.--bs-panel-inset-bottom12pxinset only. Also
added to the off-screen translation, so the sheet clears the edge fully.
--bs-panel-box-shadow--bs-handle-color#bbb--bs-handle-width50px--bs-handle-height5px--bs-content-padding20px--bs-content-padding-block0--bs-content-padding, which is horizontal only, so adding it never
doubles up with a content wrapper that already pads itself.
--bs-footer-padding--bs-content-padding--bs-footer-backgroundtransparent--bs-panel-hidden-offset20px100% is only the panel's
own height, so the sheet stops the instant its top edge clears the fold — which reads
as the motion being cut short. Applied to the hidden, showing and hiding transforms
alike, so opening and closing stay symmetrical.
--bs-transition-duration400ms--bs-snap-duration400ms--bs-snap-timingcubic-bezier(0.2, 1.25, 0.3, 1)spring="none". Any
cubic-bezier whose second control point exceeds
1 overshoots; overshoot is a share of the travelled distance, so check
that your tallest snap still clears the viewport.
--bs-transition-timingcubic-bezier(0.32, 0.72, 0, 1)ease softens the start, which reads as a hitch when
a drag hands off to the animation.
--bs-overlay-backgroundrgba(0, 0, 0, 0.5)--bs-overlay-blur5pxMethods & Properties
show(triggerEl)maxDisplayWidth. The optional trigger is used for focus return.
hide()snapTo(value)snapPoints are ignored
rather than clamped.
maxDisplayWidthInfinity for none. Reflects to the
attribute.
snapPointssnapnull when no snap points are declared.
panel<dialog-panel>.dialog<dialog> — the surface that receives the transform.header<bottom-sheet-header>.content<bottom-sheet-content>.footer<bottom-sheet-footer>, when present.backdrop<dialog-backdrop>, once the panel has created it.
Events
beforeShowshownbeforeHidehiddensnapChangesnapTo(). Never mid-drag, and never when it settles back where it
started.
The first four bubble, are composed, and are dispatched by the parent panel. Each detail
object carries state, triggerElement, and result.
snapChange comes from the <bottom-sheet> itself and
carries { from, to } in dvh percent.
Accessibility
Modal semantics, focus trapping, focus return, Escape handling, and body scroll lock are
delegated to the native <dialog> via showModal(), driven
by the parent panel — the sheet adds no ARIA of its own. Give the dialog an accessible
name with aria-labelledby or aria-label, label icon-only close
buttons, and keep a visible close control in the header for anyone who can't perform the
gesture. Any element with data-action-hide-dialog closes the sheet.
Browser support
Modern browsers with custom elements, native <dialog>, Pointer Events,
and :has(): Chrome 105+, Edge 105+, Safari 15.4+, Firefox 121+. Pointer
Events mean mouse, pen, and touch all take the same path, so every gesture on this page
works with a mouse.