@magic-spells/sheet

A sheet that magically morphs between shapes.

Bottom, left, right, or a card floating clear of all four sides. Bottom-sheet snap points you can drag between, flicks that step exactly one, and a real spring — not a transition — carrying every landing.

Install npm install @magic-spells/sheet
Snap

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.

Not open yet…
<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 }
});
Edges

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;
}
Card

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; }
Responsive

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.

Measuring the viewport…
// 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>
Limit

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.

Measuring the viewport…
// declines above the width rather than presenting badly
<sheet-panel max-display-width="900">…</sheet-panel>
Effects

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;
Leaving

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.

Not open yet…
<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;
Footer

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));
}
Lifecycle

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());
Lab

Try it out for yourself.

Position
Mobile
Desktop
Mode
Mobile ignored by center
Desktop ignored by center
Entrance effect
Mobile
Desktop
Exit effect
Mobile
Desktop
Trigger morph off = the entrance effect above · on = grow from the opener
Dismiss routes
Snap points mobile bottom only · none = 85vh
Spring · entrances only

Presets in force — no spring attribute set.


			
Reference

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

Element
Required
Role
<dialog-panel>
Yes
Peer component. Owns the native dialog, the backdrop, and the lifecycle events.
<sheet-panel>
Yes
Presentation and gestures. Resolves the active profile and drives the spring.
<sheet-header>
Recommended
Draws the drag handle on touch screens below the breakpoint. An unconditional drag surface.
<sheet-content>
Yes
The flexing, scrollable region. Yields the gesture at its scroll edge.
<sheet-footer>
Optional
Pinned bar. Drag surface, and owns the safe-area padding.

Attributes

Attribute
Default
Description
snap-points
85vh
Space-separated CSS lengths — px, 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-snap
last
Zero-based initial bottom-sheet snap on the mobile profile.
position
bottom
Mobile placement: bottom, 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.
mode
edge
Mobile geometry: edge welds it to one side, card insets it on all four.
effect
slide
Mobile motion: slide, fade-scale, or slide-fade.
exit-effect
effect
Mobile exit effect. Same three values; falls back to effect.
breakpoint
768
Viewport width in pixels at or above which the desktop profile applies. Crossing it while open morphs the sheet in place without closing or emitting beforeHide or hidden.
desktop-position
center for a bottom mobile position, else that position
Desktop placement: the same four values. A bottom sheet falls out to 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-mode
card
Desktop geometry: edge 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-effect
fade-scale for center, else effect
Desktop motion effect. Same three values.
desktop-exit-effect
exit-effect, else desktop-effect
Desktop exit effect. Same three values; falls back through exit-effect to desktop-effect.
dismiss
all routes
Token list of swipe, 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-width
none
Largest viewport width, in pixels, at which show() will open the sheet.
spring
presets
Spring override as "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

Property
Default
Description
--sheet-active-size
min(26rem, 90vw) on sides
The one fixed width of a left or right sheet, and the active snap height of a mobile bottom sheet. Override it in CSS. Never published for a content-sized profile — center, or a desktop bottom.
--sheet-panel-background
white
Panel background.
--sheet-panel-border-radius
25px
Edge-sheet exposed corner radius. The welded corners stay square.
--sheet-card-margin
12px
Card inset from every viewport edge.
--sheet-card-border-radius
20px
Card corner radius, on all four corners.
--sheet-panel-box-shadow
layered shadow
Panel elevation. This page overrides it per profile — a contact shadow plus a wide ambient one, both cast toward the edge the panel actually exposes, under a 1px inset catch-light along the top.
--sheet-handle-color
#bbb
Drag-handle fill.
--sheet-handle-width
50px
Bottom-sheet handle width.
--sheet-handle-height
5px
Bottom-sheet handle height. Also its corner radius.
--sheet-handle-side-length
--sheet-handle-width
Side-sheet handle length, running down the panel.
--sheet-handle-side-thickness
4px
Side-sheet handle thickness. A left or right sheet takes its pill from this pair, not from the two above.
--sheet-handle-offset
8px
Handle distance from the header's leading edge.
--sheet-bleed
120px
How far the panel background extends past an edge sheet's own edge, so a rubber-band overshoot never exposes the backdrop. Edge modes only.
--sheet-content-padding
20px
Content inset, and the header's horizontal inset.
--sheet-footer-padding
--sheet-content-padding
Footer inset. The safe-area inset is added beyond it.
--sheet-footer-background
transparent
Footer background.
--sheet-overlay-background
rgba(0, 0, 0, 0.5)
Backdrop fill, on the generated <dialog-backdrop>.
--sheet-overlay-blur
5px
Backdrop blur radius.
--sheet-desktop-panel-width
min(26rem, 90vw)
Maximum width of a desktop card.
--sheet-center-width
min(28rem, 100vw - 2 * card margin)
Width of a center dialog; its height follows its content.
--sheet-exit-cushion
28px
How far past its edge a dismissal carries the panel, on top of its size and inset. Raise it to about the shadow blur if the panel leaves a halo behind.
--sheet-morph-duration
600ms
Profile-morph duration and trigger-blob reduced-motion gate; 0 swaps a profile instantly and skips the blob.
--sheet-morph-easing
cubic-bezier(0.34, 1.32, 0.52, 1)
Profile-morph easing; overshoots slightly by default.
--sheet-blob-z-index
1002
Trigger-morph blob layer, above the scrim at 1000 and panel at 1001.
--sheet-backdrop-progress
written per frame
Read-only. Drives the overlay from the dismissal zone; always 01.
--sheet-progress
written per frame
Read-only. Exactly what was painted — a bottom sheet publishes its snap breath up to about 1.024; no other profile exceeds 1.

Methods & Properties

Name
Type
Description
show(triggerEl)
method
Resolves the active profile and opens through dialog-panel's engine transport. Pass the real opener so focus returns to it.
hide()
method
Runs the exit spring and closes through dialog-panel.
snapTo(index)
method
Springs a bottom sheet to a zero-based mobile snap index. Inert on sides.
activeSnap
number
Current zero-based snap index. Read-only.
dismissPolicy
object
Read-only { swipe, backdrop, escape } booleans parsed from dismiss.
panel
element
Parent <dialog-panel>.
dialog
element
Parent native <dialog> — the surface the engine animates.
header
element
Descendant <sheet-header>.
content
element
Descendant <sheet-content>.
footer
element
Descendant <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

Event
Cancelable
When it fires
beforeShow
Yes
Before the opening spring starts.
shown
No
After the sheet settles and the native dialog enters the top layer.
beforeHide
Yes
Before the exit spring starts.
hidden
No
After exit and native dialog cleanup.
snapchange
No
After the sheet settles on a different snap. Detail is { from, to } as zero-based indices. Never mid-drag.
snaprelease
No
After any claimed touch release resolves. Detail includes { 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.

Within 2 miles Nearby

  • Fen & Rye Bakery · closes at 4 0.2 mi
  • The Wick Coffee · open until 6 0.3 mi
  • Harbour Books Bookshop · open until 7 0.4 mi
  • Salt Yard Wine bar · opens at 5 0.6 mi
  • Morning Glass Coffee · closes at 2 0.7 mi
  • Ostrich Lane Records · open until 8 0.8 mi
  • Pell & Co Hardware · closes at 5 0.9 mi
  • The Long Room Pub · open until 11 1.1 mi
  • Verge Plants · closes at 6 1.2 mi
  • Bellwether Cheesemonger · closes at 6 1.3 mi
  • Tin Shed Bicycle repair · closes at 5 1.5 mi
  • Quiet Street Picture framing · by appointment 1.6 mi
  • The Aviary Tea room · open until 6 1.8 mi
  • Corner Larder Grocer · open until 9 2.0 mi

36vh · 97vh Preview or commit

Two snaps far apart read as a decision rather than a range: preview at the bottom, commit at the top.

Lower 36vh
Upper 97vh

36vh is also the lowest snap, so a dismissing drag slides the panel away at that height rather than squashing it.

25 · 45 · 68 · 92vh Four rungs

Four rungs let the sheet meet the content wherever it suits. A flick past 0.5 px/ms still moves exactly one rung, so a hard throw from the bottom lands on 45vh — never straight to the top.

Those buttons call snapTo(index) directly, which springs to a rung without a gesture.

Preferences Settings

Appearance Dark
Playback quality High
Downloads Wi-Fi only
Notifications Mentions
Storage used 2.4 GB

Drag left, or flick, to move this panel back toward the edge it came from.

128 results Filters

Sort Newest
Availability In stock
Price Up to $80
Material Linen, wool

One fixed width: min(26rem, 90vw) from the --sheet-active-size CSS fallback. Set that token in your stylesheet to override it; snap-points is ignored for side positions.

Edge · flush on three sides Queue

Low Country
Wren Alder · Tideline
  • Hollow Hour Wren Alder 4:12
  • Copper Wire The Longer Way 3:47
  • Second Thaw Marisol Vane 5:03
  • Nightbus Halden Row 2:58
  • Salt Flats Wren Alder 6:21
  • A Held Note Ivo Kessler 3:30
  • Weather, Later The Longer Way 4:44

Full width, flush to the bottom, and only the two top corners rounded. Close this and open the inset card — same list, same snaps, detached on all three sides.

Inset card · 12px all round Queue

Low Country
Wren Alder · Tideline
  • Hollow Hour Wren Alder 4:12
  • Copper Wire The Longer Way 3:47
  • Second Thaw Marisol Vane 5:03
  • Nightbus Halden Row 2:58
  • Salt Flats Wren Alder 6:21
  • A Held Note Ivo Kessler 3:30
  • Weather, Later The Longer Way 4:44

A gap on the left, the right and the bottom, and all four corners rounded — so the card's radius never has to agree with the display's. The snaps are unchanged.

bottom → right drawer Merino Crew

Price $148.00

Size

A peek at 48vh that pulls up to 92vh on a phone, and a right-hand drawer at or above 768px. Drag the window across the breakpoint with this open and watch the box travel rather than close.

The exits differ too: on a phone it slides back down, while the desktop drawer shrinks away via desktop-exit-effect="fade-scale".

Total $148.00

bottom → centred dialog Discard draft?

This draft has unsaved changes. Discarding cannot be undone. On a desktop this is a centred modal; it is still swipe-dismissible, so pull it downward to send it away.

bottom → left drawer Refine

In stock only On
Price Under $200
Colour Any
Material Wool

desktop-mode is left at its card default here, so the desktop drawer floats by --sheet-card-margin rather than sitting flush.

max-display-width 900 Narrow screens only

Above 900px, show() returns without opening. No profile switch, no fallback — the component simply declines and leaves the decision to you.

On a wider screen this is where you would reach for a centred dialog or an inline panel instead.

effect="slide" Travels the whole way

Starts fully off its edge and travels the whole way in. Nothing fades, nothing scales.

The default, and the right choice for anything welded to an edge.

effect="fade-scale" Arrives in place

Opacity from 0, scale from 0.95, no translation at all. It appears where it belongs rather than travelling to get there.

A centred dialog uses this by default. Anything else inherits effect, so say desktop-effect="fade-scale" to get it.

effect="slide-fade" A short walk in

24px of travel while the opacity comes up — along whichever axis the sheet dismisses on. Just enough motion to feel like it arrived.

The most restrained of the three, and the easiest to repeat often.

exit-effect · inherited Slides back out

Say nothing and leaving mirrors arriving. Drag this downward and release: it carries on the way your finger was going and travels clear off the bottom edge.

It is a card floating --sheet-card-margin off that edge, and the exit clears that gap too — nothing left showing.

exit-effect="fade-scale" Shrinks away

Arrives on a slide, leaves on a shrink. Drag it downward and release: it keeps moving the way your finger was going while it shrinks, so the exit continues the gesture instead of contradicting it.

Dismissed from rest — by a button, say — it scales down exactly in place.

dismiss="none" Delete this account?

No close button, and no way around it. Swipe it, throw it, click the overlay, press Escape — all four refuse, and the panel springs back rather than sitting where you left it.

The two buttons still work — they are the answer, not a way out of answering.

  • Linen apron Natural · one size $48.00
  • Stoneware mug Ash 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 at either snap and the bar below holds still. It is a sibling of the content, not part of it.

Subtotal $138.00

Lifecycle Watch the log

beforeShow and shown have already fired. Move between the three snaps to add snapchange lines, then close to finish the cycle.

Dismissing by flick logs exactly the same pair as pressing the close button — the route out does not change the record.

Your configuration Notifications

Replies Push
Mentions Push · email
Weekly digest Sunday
Product news Off

This panel is running exactly the attribute set printed under the controls. Close it, turn something, and open it again.

Reference Small on purpose

Compose the sheet with ordinary HTML, style it through custom properties, and listen on the parent panel. There is no configuration object and no theme system.

Everything the tables describe is running in this card right now — including the handle you are about to grab.