@magic-spells/bottom-sheet

A sheet with momentum.

A bottom sheet built on the native dialog. It follows the pointer, dismisses on a flick, steps aside for scrolling content, and keeps its footer above the safe area.

Install npm install @magic-spells/bottom-sheet @magic-spells/dialog-panel
8.0 kB min + gzip, everything
Anatomy 3 nested elements

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">&times;</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)
Handoff mid-gesture

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 40 · 70 · 100 dvh

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.

Not open yet…
<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 }
});
Composite 25 · 55 · 92 dvh

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
Footer flex-shrink: 0

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));
}
Inset detached · all corners

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);
Combined inset · 40 · 97 dvh

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>
Limit max-display-width 768

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.

Measuring the viewport…
<bottom-sheet max-display-width="768">…</bottom-sheet>

// or from script — Infinity removes the limit
sheet.maxDisplayWidth = 768;
sheet.maxDisplayWidth = Infinity;
Theme --bs-*

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.

Snap points off
// 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 4 events

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());
Reference 1 attribute · 14 tokens

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

Element
Required
Role
<bottom-sheet>
Yes
Gesture manager. Delegates show and hide to the parent panel.
<bottom-sheet-header>
Recommended
Draws the drag handle. Always a drag surface.
<bottom-sheet-content>
Yes
The flexing, scrollable region.
<bottom-sheet-footer>
Optional
Pinned bar. Drag surface, and owns the safe-area padding.

Attributes

Attribute
Default
Description
max-display-width
none
Largest viewport width, in pixels, at which the sheet may open. Reflects the maxDisplayWidth property. Also closes an open sheet on resize past the limit.
snap-points
none
Comma or space separated resting heights as percentages of the viewport height, e.g. "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.
snap
shortest snap
The snap the sheet rests at. Set it yourself to choose the opening height; after that the component reflects it, on commit only — it holds its last settled value for the duration of a drag. Ignored when no snap points are declared.
inset
absent
Detaches the sheet from the screen edges: all four corners rounded, a gap on three sides, and a corrected off-screen transform. Pure CSS — no script reads it.

CSS Custom Properties

Property
Default
Description
--bs-panel-background
white
Sheet background.
--bs-panel-max-height
85vh
Maximum sheet height. Inert once snap-points is set — the tallest snap becomes the cap.
--bs-panel-border-radius
25px
Top corner radius. The bottom corners stay square unless inset is set.
--bs-panel-bleed
60px
A hidden skirt of panel colour below an edge-anchored sheet. An upward rubber-band drag lifts the sheet off the bottom edge; without this the page shows through the gap. Implemented as a spread-less offset box-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-x
12px
Gap on the left and right. inset only.
--bs-panel-inset-bottom
12px
Gap below the sheet, added on top of the safe area. inset only. Also added to the off-screen translation, so the sheet clears the edge fully.
--bs-panel-box-shadow
layered shadow
Sheet elevation.
--bs-handle-color
#bbb
Drag-handle color.
--bs-handle-width
50px
Drag-handle width.
--bs-handle-height
5px
Drag-handle height. Also its corner radius.
--bs-content-padding
20px
Horizontal inset for the header and content.
--bs-content-padding-block
0
Top and bottom inset on the scrollable content. Separate from --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
Footer inset. The safe-area inset is added below it.
--bs-footer-background
transparent
Footer background.
--bs-panel-hidden-offset
20px
Extra travel past the bottom edge when hidden. 100% 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-duration
400ms
Open, close, and backdrop-fade duration.
--bs-snap-duration
400ms
Settle onto a snap point. Matches the open and close duration, but stays a separate property so the settle can carry its own curve — what differs is the arrival, not the length.
--bs-snap-timing
cubic-bezier(0.2, 1.25, 0.3, 1)
Timing function for the snap settle, and the only curve here that does not decelerate to a dead stop: it reaches the snap at 38% of the duration, drifts about 2% of the travelled distance past it, then eases back — roughly what the spring produces at its default tuning. Only reaches the sheet with 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-timing
cubic-bezier(0.32, 0.72, 0, 1)
Transition timing function. Decelerate-only by default — every transition here follows a release or a deliberate trigger, so the motion starts at speed and settles. A symmetric curve like ease softens the start, which reads as a hitch when a drag hands off to the animation.
--bs-overlay-background
rgba(0, 0, 0, 0.5)
Backdrop fill.
--bs-overlay-blur
5px
Backdrop blur radius.

Methods & Properties

Name
Type
Description
show(triggerEl)
method
Opens through the parent panel. Returns early above maxDisplayWidth. The optional trigger is used for focus return.
hide()
method
Clears any gesture transform, then closes through the parent panel.
snapTo(value)
method
Animates to a declared snap. Values that aren't in snapPoints are ignored rather than clamped.
maxDisplayWidth
number
Responsive limit in pixels, or Infinity for none. Reflects to the attribute.
snapPoints
number[]
The parsed snaps, ascending. Returns a copy — mutating it does nothing. Assign an array or a string to set them; empty restores two-state mode.
snap
number | null
The current resting snap, falling back to the shortest when none is pinned. null when no snap points are declared.
panel
element
Parent <dialog-panel>.
dialog
element
Parent <dialog> — the surface that receives the transform.
header
element
Descendant <bottom-sheet-header>.
content
element
Descendant <bottom-sheet-content>.
footer
element
Descendant <bottom-sheet-footer>, when present.
backdrop
element
Generated <dialog-backdrop>, once the panel has created it.

Events

Event
Cancelable
When it fires
beforeShow
Yes
Before opening begins.
shown
No
After opening completes.
beforeHide
Yes
Before closing begins.
hidden
No
After closing completes.
snapChange
No
When the sheet settles on a different snap, by gesture or by snapTo(). 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.

A simple beginning

This whole header is the drag surface — grab the bar, the title, the space beside it, and pull down. Escape and the backdrop close it too, and focus goes back to the button that opened it.

A list with room to move

Scroll down, then drag down — the list keeps the gesture. Come back to the top and drag again, and the panel takes it.

  • 01 · First light
  • 02 · A warm threshold
  • 03 · Notes in the margin
  • 04 · A longer route home
  • 05 · The soft edge
  • 06 · Quiet machinery
  • 07 · A useful pause
  • 08 · Small signals
  • 09 · An open window
  • 10 · Weather, later
  • 11 · The short way back
  • 12 · A held note
  • 13 · Something left out
  • 14 · Back at the beginning
Linen apron Natural · one size $48.00
Stoneware mug Ember glaze · set of two $36.00
Beeswax candle Unscented · 40 hours $22.00
Cotton tea towel Woven stripe · pair $18.00
Olive wood spoon Hand finished $14.00

Scroll this list and the bar below holds still. It's a sibling of the content, not part of it.

Total $138.00

Only under 768px

Widen the window past 768px without closing this first — the resize listener notices the boundary and closes the sheet for you.

Pick a height

Drag slowly and let go — you land on whichever of 40, 70 and 100 was nearest. Flick instead and you step exactly one, however hard you throw it. Dragging below 40 is the only way to close this by gesture.

This footer stays put at every snap

Nearby

  • Fen & Rye · Bakery · 0.2 mi
  • The Wick · Coffee · 0.3 mi
  • Harbour Books · Bookshop · 0.4 mi
  • Salt Yard · Wine bar · 0.6 mi
  • Morning Glass · Coffee · 0.7 mi
  • Ostrich Lane · Records · 0.8 mi
  • Pell & Co · Hardware · 0.9 mi
  • The Long Room · Pub · 1.1 mi
  • Verge · Plants · 1.2 mi
  • Bellwether · Cheese · 1.3 mi
  • Tin Shed · Bikes · 1.5 mi
  • Quiet Street · Framing · 1.6 mi
  • The Aviary · Tea · 1.8 mi
  • Corner Larder · Grocer · 2.0 mi

Off the edges

All four corners are rounded and there's a gap on three sides. Every gesture works exactly as it does on an edge-anchored sheet — the attribute only touches CSS.

The footer keeps the rounded bottom corners

Both at once

Floating, and snapping at 40 and 97 — nothing in between, so it either peeks or takes the screen. Drag below 40 and it dismisses, clearing the bottom gap on the way out.

Pinned at 40 and 97 alike

Turn the dials

A modal <dialog> makes everything outside it inert, so the sliders are genuinely out of reach right now. Close this, turn something, open it again.

Lifecycle in motion

Two events have already fired. Close this sheet to finish the sequence, then read the log back in the section — a flick logs the same pair as the button.

Small by design

The public API stays deliberately narrow: compose with ordinary HTML, style through custom properties, listen on the parent panel. All of it is running in this sheet right now.