All notable changes to this project are documented here.
- The default import dropped from 18.0 to 16.9 kB gzip. A pay-for-what-you-use pass moved three optional systems out of the initial graph: the keyboard engine loads on the first editable focus, so a sheet with no inputs never fetches it; the
desktopSidere-present system ships only to Roots that configure it; and the content-height morph defers off the critical path. No API changed, and each system behaves as it always did once loaded. If one of those chunk fetches fails, that single system stays off (no keyboard inset tracking, no morph spring) and the sheet keeps working, the same degradationthemeColorDimmingand the no-<dialog>fallback already use. keyboardExpandspromotes once per keyboard session. Two state-machine holes closed while making the engine lazy: a stray visualViewport tick after the user dragged away from the promoted detent could re-promote the sheet, and an internal effect re-run could misread a still-open keyboard as a fresh rising edge.- Fixed: three narrow phantom-guard edges found by the 1.0.1 review. Held gestures stay credited past the attribution window:
touchmoveand buttons-heldpointermovenow stamp the input latch, and a hover move never does. The keyboard resync's jump carries a one-shot mark so the classifier skips its frame. A detent change landing while a wind-back tween passes near its target retargets the tween instead of skipping, closing a state/position divergence.
- Fixed: a scroll nobody performed could silently dismiss an open sheet.
settle()trusted any settling track position, so a single-step programmatic displacement that landed the mandatory-snap track on its closed stop read as the user dismissing. Browser find-in-page, a focus scroll, an extension, and test tooling'sscrollIntoViewIfNeededall produce one; the live reproduction was a consumer app's sheet closing itself mid-session under Playwright device emulation. Settling positions are now origin-attributed with a two-factor classifier: only a scroll event that both teleports farther than any finger step (120px) and arrives with no recent input on the dialog (1.5s) is treated as phantom and wound back to the resting detent. Trains of ordinary steps stay trusted no matter how input-stale, so gestures that produce no DOM input events (a screen reader's own scroll, a long native momentum coast) keep dismissing exactly as before. The input stamp resets per presentation, so a tap in one session never vouches for a jump in the next. The no-input-yet state counts as stale by definition, which closes a startup blind window where adefaultOpensheet on a page younger than 1.5s was unguarded. Scope: the guard covers sheets whose resting detent stands taller than the 120px jump floor. A compact sheet below it stays exempt by design, because its closed-stop hop is geometrically identical to a screen reader's own single-jump dismiss and winding that back would trap assistive-tech users. Opt out per sheet withphantomScrollGuard={false}onSheet.Rootif your integration legitimately jump-scrolls the track outside the sheet's own APIs.
First stable release. If you're coming from the betas, the one behavior change is below; everything else shipped in the beta entries that follow.
- Changed: sheets default to the light palette everywhere. OS dark scheme no longer flips an unthemed app dark on its own. Set
data-scrollsheet-theme="dark"on an ancestor (typically<html>) to force dark, or"system"to restore the old OS-scheme tracking. Token overrides (--scrollsheet-bg,--scrollsheet-fg, and the rest) are unaffected: they keep winning in every mode.
Responsive presentation profiles, and a real fix for how centered dialogs degrade.
desktopSideanddesktopBreakpointonSheet.Root. A mobile bottom sheet that opens as a centered dialog, drawer, or side panel at the breakpoint (default 768px, the same line the desktop presentation uses):side="bottom" desktopSide="center", two props instead of a matchMedia hook. Resolved SSR-safe after mount; crossing the breakpoint while open re-presents instantly, both directions. Unset, nothing changes. Costs 0.42 kB gzip on the Sheet entry.- Fixed:
side="center"(and therefore everyDialogconsumer) degraded to a bottom-sheet-shaped fallback on the ~4% of browsers without<dialog>. The no-dialog fallback now has a real centered variant sharing the same width guard variables. Found while measuring CSS reachability for this release. - Fixed: a centered sheet with
themeColorDimmingtreated the bottom browser bar as sheet-attached and mounted bottom-edge overlays it can never use. - The full test matrix now includes an iPhone SE class small-viewport project (375x667), which promptly caught a spec bug of ours; the docs gained a trigger-morph recipe (View Transitions, zero library bytes) and a responsive profiles guide.
A bundle-size pass driven by a real design-system migration, a first-class centered dialog, and a Radix Dialog adapter.
Smaller. Every entry point, gzip, beta.3 to beta.4:
| Import | beta.3 | beta.4 |
|---|---|---|
Sheet |
18.6 kB | 17.2 kB |
Drawer (vaul compat) |
19.8 kB | 18.0 kB |
Toaster, toast |
6.8 kB | 6.6 kB |
| all four | 25.6 kB | 23.6 kB |
Sheet from /auto |
22.6 kB | 21.3 kB |
- Dev-only warning messages no longer ship in production bundles. The package now has
developmentandproductionexport conditions; every modern bundler picks the right tree automatically, and a bundler that knows neither simply gets the production build. themeColorDimming's engine loads from its own chunk only when the prop is set. Non-users stop shipping it.- The no-
<dialog>fallback modal loads from its own chunk only on the ~4% of browsers that need it, preloaded at mount there so the first open shows no gap.
Centered dialog. side="center" is a real presentation now, not a CSS trick: content-sized panel centered on both axes, zoom+fade on the same spring the sheets use, keyboard-aware, drag-free. Your CSS owns the width; core only guards the maximum (--scrollsheet-center-max-inline, default min(560px, 100%)).
Radix Dialog adapter. import { Dialog } from 'scrollsheet' keeps @radix-ui/react-dialog JSX working against the centered presentation: Root/Trigger/Portal/Overlay/Content/Title/Description/Close, data-state stamping for existing animation CSS, and one-time dev warnings that name the scrollsheet recipe for each stripped radix prop. Dismissal is click-based on purpose: iOS Safari can deliver a backdrop tap as a bare click with no pointerdown, which the outside-pointerdown model misses. Sheet-only consumers ship none of this.
Toasts. sonnerCompat={false} on the Toaster renders neutral .scrollsheet-toast names only, for fresh integrations with no Sonner legacy. The default stays dual-stamped; migrated CSS keeps matching.
Gaps a design-system migration hit in the compat layers, closed same day.
Drawer.Rootnow forwards the scrollsheet-native props that have no vaul counterpart:backdropDismissible,escapeDismissible,keyboardExpands,onTravel,scrollbar,actionsRef. The RadixonPointerDownOutside-preventDefault idiom becomesbackdropDismissible={false}, and the dev warning for the stripped Content props now says so.- Per-toast
style(andtoastOptions.style), matching Sonner'sExternalToast.style: inline styles land on the toast row over the stack's own variables, sotoporzIndexoverrides stick. Per-toast wins over the Toaster's base. - Docs: side sheets size from detents, not CSS width (a vaul-era
width: 360pxmaps tosnapPoints={['360px']}). Design systems whose CSS pipeline compiles custom properties away (postcss-css-variables and similar) should import fromscrollsheet/auto.
First real-device findings, same day.
- Fixed: closing a sheet on iOS visibly scrolled the whole page when the host page sets
scroll-behavior: smooth(a common global style). The body-freeze restore now jumps back instantly. - Fixed: collapsed toast stacks recede properly: back cards scale down behind the front one. The ported formula was mirrored and growing instead.
- Toast card radius is now
--scrollsheet-toast-radius, default 14px (was a hard-coded 8px).
First public release, on npm's beta dist-tag while the real-device pass finishes.
Core
- Native
<dialog>in the top layer. A CSSscroll-snaptrack does the moving; there is no pointer-physics simulation. Desktop mouse drags suspend snap so the sheet tracks the pointer exactly. - Four sides: bottom, top, left, right. Side sheets block the browser's own edge back-swipe while dragging.
- Multi-detent snap points, non-modal mode,
asChildonTrigger/Close/Handle/Title/Description. - Spring physics compiled to CSS
linear(), with interruptible WAAPI enter/exit. actionsRef:open(),close(),snapTo(detent)without a controlled prop.--scrollsheet-stack-progress(0-1) on a receded parent panel, for your own depth effect.- Desktop (768px and up): bottom sheets become a floating card, side sheets square their lead edge. Tune with
--scrollsheet-desktop-margin,--scrollsheet-max-inline,--scrollsheet-radius. - Safe areas handled per side: status bar, home indicator, landscape notch.
- Keyboard engine reads the visual viewport and survives iOS Safari's focus scroll.
keyboardExpands: a sheet at a short peek detent grows to its tallest detent when the keyboard actually appears (a measured inset, never bare focus), and restores after. Off by default.- Keyboard clearance on every side, not just bottom; a focused field scrolls clear of the keyboard.
--scrollsheet-travel: noneturns off the enter/exit travel, for sheets whose entrance something else carries (a View Transition, say). Callbacks still fire.- Scroll-driven animations on Chrome 115+ / Safari 26+: backdrop dim and
--scrollsheet-progressrun off the main thread. Every other engine keeps the JS path, unchanged. fillonSheet.Contentstretches the body to the panel instead of natural height.onTravelgetsTravelInfo: the travelrangeplus per-detent progress. The object is reused every frame; read it, don't store it.backgroundRefpicks the exact elementbackgroundEffectscales or parallaxes.data-scrollsheet-nested-scroll: an inner scroller that hits its top hands the same swipe over to the sheet. Gated per finger; elements added or removed mid-session are handled.- Marked nested scrollers get the panel's
scrollbartreatment:overlay,hidden, ornative. closeThresholdalso governs wheel-dismiss on single-detent sheets, not just drags.injectStylesInto(shadowRoot, nonce?)for Shadow DOM setups, with a<style>fallback.- Closed-form spring solver, O(1), no integration error.
spring()throws on a curve that never converges. scrollsheet/motion(experimental): the spring/WAAPI/scroll-tween core with no React.- Modal sheets set
dialog.closedbywhere supported (Chrome 134+, Firefox 141+):closerequestornone, neverany. - Non-modal dismissible sheets create a
CloseWatcher(Chrome 126+, Firefox 149+, touch-primary devices only) so Android back closes the sheet instead of navigating. Real-device check pending. Sheet.Handlevariants:floatingoverlays full-bleed content;outsidefloats the pill above the sheet (bottom sheets only).- Self-closing
<Sheet.Close />renders a styled ✕: top-right, 44px hit area, labeled. - Two style entries: the default is css-external (
import 'scrollsheet/styles.css'), vaul's convention.scrollsheet/autoembeds the stylesheet and injects it on first open; it is also the entry for Shadow DOM and CSP-nonce setups. - Fixed: content-morph respects
prefers-reduced-motion(instant, no tween). - Fixed:
Triggerrecords itself as opener on click, sobackgroundEffectownership works on Safari too. - Fixed:
sideEffectsexempts CSS, so bundlers keep the stylesheet import. - Fixed: the
"use client"banner lands on every dist chunk. - shadcn/ui registry item (
sheet) served from the docs site. - Considered and dropped:
command/commandforonTrigger. The dialog is portaled and doesn't exist before first open, so there is nothing to pointcommandforat.
Vaul compat
import { Drawer } from 'scrollsheet'maps vaul's props (direction,modal,shouldScaleBackground,snapPoints,fadeFromIndex,closeThreshold,onAnimationEnd,onClose) onto scrollsheet.closeThresholdis converted between conventions, so vaul's 0.25 default keeps the same quarter-height dismiss it always had.Drawer.HandleacceptspreventCycle; emitsdata-vaul-handle.Drawer.Contentemitsdata-vaul-snap-pointsand warns once (dev-only) on vaul/Radix-only props instead of leaking them onto the DOM.asChildonDrawer.Contentmerges onto a single element child; a Fragment degrades to the default panel with a dev warning.
Toasts
Toaster,toast()and its variants,toast.promise(),useToasts: own primitives, noSheet.Rootinvolved. Neutral-first styling with.scrollsheet-toastclasses and--scrollsheet-toast-*custom properties.- Same architecture as Sonner: a persistent DOM node per toast, all six positions with per-toast override, 2-axis swipe-to-dismiss with a velocity flick.
visibleToasts(default 3) hides overflow instead of evicting it. Timers keep running; a hidden toast fades in when a slot frees.- Sonner drop-in kept:
useSonneralias,.sonner-toast/data-sonner-*stamps beside the neutral ones,scrollsheet/toast.css(renamed fromsonner.cssbefore publish). toast.promise()matches Sonner: a resolvedResponsewith!okor a resolvedErrorroutes to the error branch; extended-result objects;.unwrap()on the returned id.classNamesslot styling, per toast and per Toaster.iconson<Toaster>overrides per-type glyphs; a per-toasticonwins.<Toaster id>routes toasts to an instance;toasterIdstill works, deprecated.injectToastStylesInto(shadowRoot, nonce?)for a Toaster in a shadow root.- Reduced motion collapses every duration to zero.
- Not yet implemented:
dir/RTL,theme,richColors,mobileOffset, object-formoffset.
Accessibility
- Focus lands inside the panel on open;
Title/Descriptionwire the aria attributes. - A multi-detent
Handleis a slider: arrow keys, Home, End.
Size
- 18.5 kB gzipped, 16.4 kB brotli, zero dependencies, React 18+.