
Research
/Security News
737 Chrome VPN Extensions Linked to Brand Impersonation and Browser Traffic Redirection
The campaign amassed more than 75,000 installs by targeting Russian-speaking users seeking access to blocked services.
scrollsheet
Advanced tools
The bottom sheet that scrolls. Native-feeling React drawer built on <dialog> and CSS scroll-snap. Zero dependencies.
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+.
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.
<dialog>. Top layer, so no z-index wars. Focus containment, Esc, and background inerting come from the platform.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.linear() timing function at build time. No animation loop, no motion library.UISheetPresentationController interaction, emergent rather than implemented.Your page never gets touched. No position: fixed body hacks, no scroll restoration bugs, no layout shift.
bun add scrollsheet@beta # or npm/pnpm/yarn
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.
<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.
| Component | What it is |
|---|---|
Sheet.Root | State 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.Trigger | Button wired with aria-haspopup / aria-expanded |
Sheet.Content | The <dialog>, backdrop, scroll track, and panel |
Sheet.Handle | Grabber 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.Description | Wire aria-labelledby / aria-describedby |
Sheet.Close | Closes 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.
Sheet.Root inside another sheet's Content; the parent recedes iOS-style.data-scrollsheet-nested-scroll: it scrolls, and at its top the same swipe continues as sheet travel.'content' detent springs to new content height instead of jumping.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.fill prop, Shadow DOM injection, themeColorDimming, hidden scrollbars with an overlay thumb.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.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.skills/ ships migrate-from-vaul, migrate-from-sonner, and build-with-scrollsheet for coding agents.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.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.
| scrollsheet | vaul | Silk | react-modal-sheet | |
|---|---|---|---|---|
| Maintained | yes | no | yes | yes |
| Runtime deps | 0 | Radix Dialog (+24 transitive) | 0 | Motion (peer) |
Native <dialog> / top layer | yes | no | no | no |
| Gesture engine | native scroll | pointer events | native scroll | Motion drag |
| Toasts | yes, sonner drop-in | no | yes | no |
| Lightbox | yes | no | yes | no |
No-<dialog> browsers (~4%) | plain modal, no gestures | full support | full support | full support |
| License | MIT | MIT | commercial for advanced use | MIT |
| Price | free | free | paid license for the full set | free |
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.
<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>;
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.
Next: scroll() and view() animation helpers over the motion core. Later: framework adapters (Vue, Svelte, Solid) over the same React-free core.
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
FAQs
The bottom sheet that scrolls. Native-feeling React drawer built on <dialog> and CSS scroll-snap. Zero dependencies.
We found that scrollsheet demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago. It has 1 open source maintainer collaborating on the project.
Did you know?

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.

Research
/Security News
The campaign amassed more than 75,000 installs by targeting Russian-speaking users seeking access to blocked services.

Company News
Open source maintainers are under more pressure than ever. We're raising our open source program from the Team plan to the Business plan, free.

Security News
The supply chain control that delays freshly published gems now covers lockfile generation and gem vendoring in Ruby projects.