All notable changes to this project are documented here.
1.0.0-beta.1 - 2026-08-01
First public release, as a beta on npm’s beta dist-tag while the real-device pass
finishes. Everything below ships in it.
Core
- Native
<dialog>top layer, driven by a CSSscroll-snap-type: y mandatorytrack instead of a pointer-event physics simulation. Desktop mouse drags suspend snap for the session so the sheet tracks the pointer exactly. - Four sides: bottom, top, left, right; side sheets opt out of the browser’s own edge swipe-to-navigate gesture while dragging.
- Multi-detent snap points, non-modal mode,
asChildonTrigger/Close/Handle/Title/Description. - Spring-to-
linear()easing with interruptible WAAPI enter/exit legs. - Imperative
actionsRef(open()/close()/snapTo(detent)) for controlling a sheet without a controlled prop. - Public
--scrollsheet-stack-progressCSS var (0-1) on a receded parent panel, for a consumer’s own stacking depth effect. - Desktop presentation defaults at 768px and up: bottom sheets dock right as a floating card with margins, side sheets square their lead edge;
--scrollsheet-desktop-marginand--scrollsheet-max-inlinetune it,--scrollsheet-radiusrestores rounding. - Per-side safe-area handling: status-bar clearance for top and side sheets, home-indicator clearance for bottom sheets, landscape notch insets on side panels.
- Keyboard engine pinned to the visual viewport, with an inset estimate that survives iOS Safari’s focus scroll compensation.
--scrollsheet-travel: nonestyling hook to switch off the enter/exit travel animation, for sheets whose entrance is carried by something else (a View Transition morph, say). Degrades like reduced motion: the phase machine and callbacks still run, in zero time.- Scroll-driven animations: on Chrome/Edge 115+ and Safari 26+, the backdrop dim and
--scrollsheet-progressrun as CSSanimation-timeline: scroll()animations, off the main thread entirely, instead of a per-frameonTravelwrite. Feature-detected once and stampeddata-scrollsheet-sdaon the dialog; the JS travel path is unchanged and stays the only path everywhere else, including Firefox’s still-flagged support. A stacked sheet’s parent recede andbackgroundEffectstay JS-driven on every engine; both live outside the track’s own DOM subtree. fillprop onSheet.Contentstretches[data-scrollsheet-body]to the panel (flex column) instead of sizing to natural content height, promoting the docs “Full-height content” recipe into the library. Thecontentdetent measurement and content-morph both switch to the body’s first child whenfillis set.onTravel’s third argument,TravelInfo, carries the resolved travelrangeand per-detentprogressAtDetents(0-1, keyed by each detent’s resolved px height). The object is reused and mutated in place every travel frame, not allocated fresh. Read it synchronously, don’t store the reference.backgroundRefgivesbackgroundEffectan explicit target to scale or parallax, skipping the document-wide[data-scrollsheet-background]query. The implicit default’s ownership check is unchanged.data-scrollsheet-nested-scrollon a scrollable element inside the body hands its scroll back to the sheet: once the element sits at its top boundary during a touch that started inside it, the same swipe continues as sheet travel, even underhandleOnlyordisableDrag. Gating is per touch identifier, so an unrelated second finger never widens the drag surface; elements a content morph adds or removes mid-session are picked up and cleaned up without re-wiring.- Marked nested scrollers now get the panel’s own
scrollbartreatment:'overlay'hides the native scrollbar and shows an auto-hiding thumb,'hidden'hides it with no thumb,'native'leaves it untouched. Discovery is delegated and observed the same way as the handoff above, so scrollers a morph adds or removes are picked up without re-wiring. closeThresholdnow governs wheel-dismiss gestures on single-detent sheets, not just pointer drags. Native scroll-snap still drives every frame; a wheel-idle correction pass only overrides the resting position when the accumulated gesture disagrees with where native snap actually landed.injectStylesInto(shadowRoot, nonce?)adopts the core stylesheet into a shadow root viaadoptedStyleSheets, for a sheet whose trigger and surrounding markup live behind shadow encapsulation. Falls back to a<style>tag scoped inside the root on engines without constructable stylesheets. The dialog itself still portals todocument.bodyregardless.- Closed-form spring solver:
sampleSpringAtis O(1) with no integration error.spring()now throws instead of silently truncating a curve that never converges within its max duration. scrollsheet/motion(experimental): the spring/WAAPI/scroll-tween/geometry core as its own zero-React subpath, for driving your own motion off the same math the sheet runs on.keyboardExpandsonSheet.Root: a sheet resting at a short peek detent promotes to its tallest detent the moment the software keyboard actually appears (a measured nonzero inset, never focus alone, so a desktop click into an input expands nothing), and restores the peek detent when the keyboard closes unless a drag or controlled change moved the sheet in between. Off by default.- Keyboard clearance for top/left/right sheets:
--scrollsheet-keyboardis now tracked on every side, and edge-anchored panels turn it into bottom padding on[data-scrollsheet-body]plusscroll-paddingon the panel, so a focused field near the bottom of a side drawer scrolls clear of the keyboard instead of sitting under it. The focus auto-reveal runs on every side too, previously bottom-only. - Fixed: content-morph respects
prefers-reduced-motion: a detent-geometry change now lands instantly (scroll position and snap stops synced in one frame) instead of tweening the morph. - Fixed:
Triggernow records itself as the opener on click, so the implicitbackgroundEffectownership check also fires on Safari, where WebKit never focuses a clicked button. - Fixed:
sideEffectsnow exempts**/*.css, so a webpack/Next.js production build keepsimport "scrollsheet/styles.css"instead of tree-shaking it away. - Fixed: the
"use client"banner now lands on every dist chunk, not just the entry point. Rolldown drops it from shared chunks otherwise. - shadcn/ui registry item (
sheet) served from the docs site. - Modal sheets set
dialog.closedbywhere the engine supports it (Chrome/Edge 134+, Firefox 141+):closerequestwhenescapeDismissibleis on,nonewhen it’s off, so Esc and Android back route through the platform’s own close-request machinery. Neverany:.scrollsheet-dialogalways covers the full viewport, so there’s no “outside the dialog” for a native light-dismiss to detect, and it would only risk double-firing against the library’s own backdrop-click handling. Unsupported engines keep today’s manual dismissal, unchanged. - Non-modal sheets (
modal={false}) create aCloseWatcher(Chrome 144+) while open and dismissible, so Android back closes the sheet instead of navigating the page; a modalshowModal()dialog already gets this for free viaclosedby, non-modal ones don’t. Touch-primary devices only (hover: nonepluspointer: coarse): Esc is also a platform close signal whereverCloseWatcherexists, and a desktop watcher would close the sheet on Esc pressed anywhere on the page, where non-modal Esc is deliberately scoped to focus inside the sheet. Device-verification-pending: confirmed in a desktop feature-detection harness, not yet walked through on a real Android device. - Considered and dropped: declarative
command/commandforinvoker support onSheet.Trigger. The sheet’s<dialog>is portaled and only mounts after the first open, so there’s no element for acommandforreference to target before that first open, the exact caseTriggerexists to handle. No amount of wiring fixes a target that doesn’t exist yet.
Vaul compat
- Drop-in vaul compatibility:
import { Drawer } from 'scrollsheet'maps vaul’s props (direction,modal,shouldScaleBackground,snapPoints,fadeFromIndex,closeThreshold,onAnimationEnd,onClose) onto scrollsheet’s own API. closeThresholdis converted between conventions: vaul counts the fraction dragged away, scrollsheet counts the fraction still visible, so vaul’s 0.25 default maps to scrollsheet 0.75 and a migrated drawer dismisses after the same quarter-height drag it always did. Only live whensnapPointsis unset, matching vaul.Drawer.HandleacceptspreventCycleto suppress click-to-cycle; emitsdata-vaul-handle.Drawer.Contentemitsdata-vaul-snap-pointsand warns once (dev-only) instead of leaking vaul/Radix-only interaction props onto the DOM.Drawer.ContentsupportsasChildwith a single element child, merging props and ref onto it. A Fragment child (which can never receive a ref) degrades to default rendering with a dev-only warning instead of breaking the panel silently.
Sonner compat
- Drop-in Sonner compatibility:
import { Toaster, toast } from 'scrollsheet'shipstoast()/.success/.error/.info/.warning/.loading/.message/.custom/.dismiss,toast.promise(),useSonner(), and<Toaster>on scrollsheet’s own primitives. visibleToastscaps how many toasts render at once (default 3, matching Sonner’s own). Past the cap, the oldest dismissible toast evicts to make room,onDismissfiring same as a manual close. Adismissible: falsetoast holds its slot; a new arrival queues, untouched in the store, until one clears.- Animation parity: CSS-transition enter/exit, per-row swipe-to-dismiss (45px threshold, adapted from Sonner’s own), and a crossfade between the collapsed and expanded stack view.
prefers-reduced-motioncollapses every duration to zero and disables the CSS transitions and animations outright. <Toaster id>is the primary prop for routing toasts to a specific instance;toasterIdstill works as a deprecated alias (dev-only warn-once).toast.promise()matches real Sonner’s own resolve-path semantics: a resolved FetchResponsewith!ok, or a resolvedErrorinstance, both route to the error branch;success/errormay be sync or async functions; the returned id carries.unwrap(), resolving with the settled value or rejecting with the settled reason.classNamesslot-styling: a per-toastclassNamesobject and a Toaster-leveltoastOptions.classNamesdefault merge onto every rendered slot (toast, title, description, icon, loader, content, cancel/action/close buttons, and per-type).iconsprop on<Toaster>overrides the default per-type glyphs, including the loading spinner; a per-toasticonalways wins.injectToastStylesInto(shadowRoot, nonce?), a sibling of the coreinjectStylesIntofor a<Toaster>mounted inside a shadow root.- Documented gaps (not yet implemented):
dir/RTL,hotkey,mobileOffset, object-formoffset,toastOptionsfields beyond what’s listed above, the “extended result object” form ofpromise()’ssuccess/error, andReact.isValidElement/function-formdescriptionhandling insidepromise().
Accessibility
- Focus lands inside the panel on open;
Title/Descriptionwirearia-labelledby/aria-describedbyautomatically. - Multi-detent
Handleexposes slider semantics (role, value, arrow/Home/End keys).
Size
- 19.4 kB gzipped, 17.2 kB brotli, zero dependencies, React 18+. Bundled the same way, vaul ships 21.6 kB with its required Radix tree.