Sign In

scrollsheet

Package Overview
Dependencies
Maintainers
1
Versions
5
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

scrollsheet

The bottom sheet that scrolls. Native-feeling React drawer built on <dialog> and CSS scroll-snap. Zero dependencies.

beta
latest
Source
npmnpm
Version
1.0.0-beta.5
Version published
Maintainers
1
Created
Source

scrollsheet

Bottom sheets for React that feel native. Because they are.

One primitive for bottom sheets, drawers, modal dialogs, side panels, and toasts. A real <dialog> in the top layer. The browser's own scroll engine for gestures. Spring physics compiled to CSS linear(). 17.6 kB gzipped, 15.7 kB brotli, plus a mandatory 3.8 kB stylesheet: 22.3 kB combined. React 18+.

Why another drawer

vaul carried the ecosystem for two years and last shipped in December 2024. Its bug tracker tells one story over and over: pointer tracking fights the browser. scrollsheet takes the opposite bet: don't simulate the gesture, be the scroll.

  • The sheet is a native <dialog>. Top layer, so no z-index wars. Focus containment, Esc, and background inerting come from the platform.
  • The drag is a native scroll. A full-viewport scroll-snap container; detents are snap stops. 1:1 tracking, momentum, and rubber-banding run on the compositor. There is no drag code to have bugs in.
  • The motion is a real spring, compiled to a CSS linear() timing function at build time. No animation loop, no motion library.
  • Scroll handoff is free. At the top detent the browser chains the gesture into your content on its own. The UISheetPresentationController interaction, emergent rather than implemented.

Your page never gets touched. No position: fixed body hacks, no scroll restoration bugs, no layout shift.

Install

bun add scrollsheet@beta   # or npm/pnpm/yarn

Use

import { Sheet } from 'scrollsheet';
import 'scrollsheet/styles.css';

<Sheet.Root>
  <Sheet.Trigger>Open</Sheet.Trigger>
  <Sheet.Content className="my-sheet">
    <Sheet.Handle />
    <Sheet.Title>Title</Sheet.Title>
    <Sheet.Description>Says what this sheet is for.</Sheet.Description>
    <Sheet.Close>Done</Sheet.Close>
  </Sheet.Content>
</Sheet.Root>

Sheet.* is client-only: state, event handlers, real DOM. In Next.js App Router, add 'use client' to the file that renders it, same as any other interactive component.

The stylesheet carries mechanics only; visuals are yours via className. No bundler CSS handling? Import everything from scrollsheet/auto instead: same components with the stylesheet embedded, injected the first time a sheet opens (also the entry for Shadow DOM and CSP-nonce setups).

Design systems and other libraries that re-bundle their CSS: if your pipeline statically compiles custom properties away (postcss-css-variables and similar), it destroys the runtime --scrollsheet-* variables the stylesheet's geometry runs on, and sheets break in subtle ways. Import from scrollsheet/auto there instead; the injected stylesheet never enters your build.

Detents

<Sheet.Root detents={[0.35, 0.7, 'full']} activeDetent={active} onActiveDetentChange={setActive}>

A detent is 'content' (the default), 'medium', 'full', a fraction, or '320px'. The handle cycles detents on click and moves between them with arrow keys.

API

ComponentWhat it is
Sheet.RootState owner. open, onOpenChange, onOpenChangeComplete, actionsRef (open() / close() / snapTo(detent)), detents, activeDetent, onActiveDetentChange, dismissible, escapeDismissible, backdropDismissible, onTravel, nonce, side, modal, backgroundEffect, scrollbar, largestUndimmedDetent, handleOnly, disableDrag, sequentialDetents, closeThreshold, keyboardExpands, onRelease
Sheet.TriggerButton wired with aria-haspopup / aria-expanded
Sheet.ContentThe <dialog>, backdrop, scroll track, and panel
Sheet.HandleGrabber pill: click cycles detents, arrows move, down-arrow at the lowest detent dismisses. variant="floating" overlays full-bleed content; variant="outside" floats the pill in the backdrop above the sheet
Sheet.Title / Sheet.DescriptionWire aria-labelledby / aria-describedby
Sheet.CloseCloses on click. Self-closing <Sheet.Close /> renders a styled ✕ button, top-right, 44px hit area, aria-label included

Also exported: DetentSpec, Side, SheetActions, TravelInfo, spring(config?), isSupported(). Styling hooks (data-scrollsheet-state, --scrollsheet-progress, --scrollsheet-travel: none, and the rest) and the full prop reference: scrollsheet.dev/docs/reference/api.

Also built in

  • Nested sheets. A Sheet.Root inside another sheet's Content; the parent recedes iOS-style.
  • Nested scrollers. Mark an inner list data-scrollsheet-nested-scroll: it scrolls, and at its top the same swipe continues as sheet travel.
  • Content morph. A 'content' detent springs to new content height instead of jumping.
  • Keyboard engine. visualViewport-tracked insets so the keyboard never reveals the page through a gap (the classic vaul bug). Works on every side; keyboardExpands promotes a peek-detent sheet to its tallest detent while the keyboard is up.
  • Desktop mouse drag, fill prop, Shadow DOM injection, themeColorDimming, hidden scrollbars with an overlay thumb.
  • vaul drop-in. import { Drawer } from 'scrollsheet' keeps vaul's API and data-vaul-* attributes; props that existed to fight the page warn once in dev. Migration guide.
  • Toasts, no Sonner knowledge required. import { toast, Toaster, useToasts } from 'scrollsheet' styles with .scrollsheet-toast classes and --scrollsheet-toast-* custom properties: .promise(), update-by-id, all six positions with per-toast overrides, swipe-to-dismiss with a velocity flick. Already on Sonner? Your .sonner-toast CSS still matches, unchanged, and useSonner/toasterId keep working. Migration guide.
  • Agent skills. skills/ ships migrate-from-vaul, migrate-from-sonner, and build-with-scrollsheet for coding agents.
  • Motion core (experimental). scrollsheet/motion is the React-free layer the sheet runs on: closed-form spring solver, interruptible WAAPI wrapper, scroll tween. 1.6 kB gzipped standalone.
  • Zero-config entry. scrollsheet/auto embeds the stylesheet and injects it on first open: no CSS import needed, 21.8 kB gzip for Sheet against the default entry's 17.6.

Focus containment comes from the platform's showModal(), not a JS focus trap. On open, focus lands on the panel so mobile keyboards don't pop unasked; use native autofocus to override.

The competition, honestly

scrollsheetvaulSilkreact-modal-sheet
Maintainedyesnoyesyes
Runtime deps0Radix Dialog (+24 transitive)0Motion (peer)
Native <dialog> / top layeryesnonono
Gesture enginenative scrollpointer eventsnative scrollMotion drag
Toastsyes, sonner drop-innoyesno
Lightboxyesnoyesno
No-<dialog> browsers (~4%)plain modal, no gesturesfull supportfull supportfull support
LicenseMITMITcommercial for advanced useMIT
Pricefreefreepaid license for the full setfree

Building on <dialog> is a real trade. The others portal a plain <div>, so their full experience reaches the ~4% of browsers with no <dialog> (Opera Mini, some old in-app WebViews, iOS ≤15.3), where scrollsheet degrades to a static modal: backdrop, tap-to-close, Escape, content reachable, gestures gone. If pixel-identical drag on iOS 15.3 is a requirement, this is the wrong library.

Browser support

<dialog>: about 96% global. The spring easing needs CSS linear() (Chrome 113+, Safari 17.2+, Firefox 112+); below that, a plain ease-out at the same duration. Chrome/Edge 115+ and Safari 26+ run the backdrop dim and --scrollsheet-progress as compositor-side scroll-driven animations; everywhere else the same values update from JS. Below Safari 15.4, the static-modal fallback above, no code required. Full matrix: browser support docs.

Want a different experience for that ~4% instead of the built-in fallback? Check client-side, after mount:

import { Sheet, isSupported } from 'scrollsheet';

const [supported, setSupported] = useState(false);
useEffect(() => setSupported(isSupported()), []);

if (!supported) return <LegacyModal open={open} onClose={onClose} />;
return <Sheet.Root open={open} onOpenChange={onClose}>...</Sheet.Root>;

shadcn/ui

Every primitive ships as a registry item:

bunx shadcn@latest add https://raw.githubusercontent.com/ansumanshah/scrollsheet/main/registry/drawer.json

Items: drawer, sheet, confirm, share-sheet, sidebar, toast. Each writes one file into components/ui/ with shadcn's default styling applied, wrapping the primitives above. Swap drawer.json in the URL for any of them. These URLs only resolve once the repo is public.

Roadmap

Next: scroll() and view() animation helpers over the motion core. Later: framework adapters (Vue, Svelte, Solid) over the same React-free core.

Development

bun install
bun run dev        # docs site with live examples (localhost:4321)
bun test           # unit tests
bun run verify     # the full gate

See CONTRIBUTING.md and the CHANGELOG.

MIT © Ansuman Shah

Keywords

react

FAQs

Package last updated on 10 Aug 2026

Did you know?

Socket

Socket for GitHub automatically highlights issues in each pull request and monitors the health of all your open source dependencies. Discover the contents of your packages and block harmful activity before you install or update your dependencies.

Install

Related posts