Docs
Docs
Install, style, and configure scrollsheet. One page, every section.
Install
bun add scrollsheet
npm, pnpm and yarn work the same way. No peer dependencies beyond React and React DOM, 18 or newer.
shadcn/ui
Every primitive and pattern also ships as a registry item, installable with the shadcn CLI:
bunx shadcn@latest add https://scrollsheet.dev/r/drawer.json
bunx shadcn@latest add https://scrollsheet.dev/r/sheet.json
bunx shadcn@latest add https://scrollsheet.dev/r/confirm.json
bunx shadcn@latest add https://scrollsheet.dev/r/share-sheet.json
bunx shadcn@latest add https://scrollsheet.dev/r/sidebar.json
bunx shadcn@latest add https://scrollsheet.dev/r/toast.json
Or straight off GitHub, no site involved:
bunx shadcn@latest add https://raw.githubusercontent.com/ansumanshah/scrollsheet/main/registry/drawer.json
bunx shadcn@latest add https://raw.githubusercontent.com/ansumanshah/scrollsheet/main/registry/sheet.json
bunx shadcn@latest add https://raw.githubusercontent.com/ansumanshah/scrollsheet/main/registry/confirm.json
bunx shadcn@latest add https://raw.githubusercontent.com/ansumanshah/scrollsheet/main/registry/share-sheet.json
bunx shadcn@latest add https://raw.githubusercontent.com/ansumanshah/scrollsheet/main/registry/sidebar.json
bunx shadcn@latest add https://raw.githubusercontent.com/ansumanshah/scrollsheet/main/registry/toast.json
Each writes one file into components/ui/: drawer.tsx, sheet.tsx, confirm.tsx,
share-sheet.tsx, sidebar.tsx, toast.tsx. confirm and share-sheet build on drawer;
sidebar builds on sheet; the CLI pulls those in automatically.
Anatomy
import { Sheet } from 'scrollsheet';
<Sheet.Root>
<Sheet.Trigger>Open</Sheet.Trigger>
<Sheet.Content>
<Sheet.Handle />
<Sheet.Title>Title</Sheet.Title>
<Sheet.Description>Description</Sheet.Description>
<Sheet.Close>Done</Sheet.Close>
</Sheet.Content>
</Sheet.Root>
Sheet.Root owns state and renders nothing itself. Sheet.Trigger opens it on click; skip it and
drive open/onOpenChange yourself for a fully controlled sheet. Sheet.Content is the
<dialog>, backdrop, scroll track, and panel together, everything you put inside it renders on
the panel. The rest are optional, each wiring one piece of behavior onto a plain element:
Sheet.Handle (<button>, drag grip), Sheet.Title (<h2>, aria-labelledby), Sheet.Description
(<p>, aria-describedby), and Sheet.Close (<button>, dismiss on click).
Styling
.sheet {
background: white;
border-radius: 16px 16px 0 0;
box-shadow: 0 -8px 40px rgb(0 0 0 / 0.16);
}
scrollsheet ships mechanics only (position, transform, scroll-snap, backdrop, focus
containment), injected as CSS the first time a sheet opens. The className on Sheet.Content
lands on the panel, so style it like any card. For CSP, pass nonce to Sheet.Root, or skip
injection and import scrollsheet/styles.css yourself.
Tailwind
<Sheet.Content className="bg-white shadow-2xl dark:bg-zinc-900">
<Sheet.Handle className="bg-zinc-300 dark:bg-zinc-600" />
</Sheet.Content>
Utilities apply directly: no plugin, no config, works with Tailwind v4 out of the box.
Set the library’s own variables with arbitrary properties, or in your stylesheet:
<Sheet.Content className="[--scrollsheet-radius:24px] [--scrollsheet-backdrop:rgb(0_0_0/0.6)]" />
State attributes live on the <dialog> ancestor, so match them with v4’s in-* variant:
<Sheet.Content className="in-data-[scrollsheet-at-max]:rounded-none" />
The mechanics stylesheet only wins where it must (position, transform). Everything visual sits
in zero-specificity :where() rules, so a Tailwind utility always overrides it.
Detents
type DetentSpec = 'full' | 'medium' | 'content' | number | `${number}px`;
<Sheet.Root
detents={[0.35, 0.7, 'full']}
activeDetent={active}
onActiveDetentChange={setActive}
/>
A detent is a resting height, resolved to a scroll-snap stop. 'content' measures natural
height, 'medium' is 50% of viewport, and 'full' is viewport minus a top inset. Any other
number is a fraction, or pixels above 1. onTravel(px, progress, info) fires every frame the
sheet moves, also published as --scrollsheet-progress. The third argument carries range
([0, maxDetent], px) and progressAtDetents, a Map from each detent’s resolved px height to
its own 0-1 progress, for driving something off a specific detent instead of only the nearest
one. It’s reused and mutated in place every frame, not allocated fresh: read it synchronously,
don’t hold onto the reference.
Imperative control
const actionsRef = useRef<SheetActions>(null);
<Sheet.Root actionsRef={actionsRef} detents={['content', 'full']}>
{/* ... */}
</Sheet.Root>
<button onClick={() => actionsRef.current?.snapTo('full')}>Expand</button>
For the cases a controlled open/activeDetent prop is awkward for (deep links, push
notifications, a descendant reaching up without prop-drilling): actionsRef.current.open(),
.close(), and .snapTo(detent) animate exactly like a controlled prop change would. They’re
thin wrappers over the same state. snapTo warns once in dev if detent isn’t in the sheet’s
configured detents, and still resolves to the nearest one rather than no-op’ing.
Keyboard
<Sheet.Content className="sheet">
<input placeholder="Card number" />
{/* stays clear of the on-screen keyboard automatically */}
</Sheet.Content>
iOS resizes visualViewport, not the layout viewport, when the keyboard opens. scrollsheet
listens to it and writes the inset to --scrollsheet-keyboard, no prop required. The panel
overdraws about 120vh past its bottom edge, so a stale frame never shows a gap while the
keyboard pushes the content up.
A sheet resting at a short peek detent has no room to show a field the keyboard covers, so
keyboardExpands on Sheet.Root promotes to the tallest detent the moment the keyboard
actually appears, and restores the peek detent when it closes, unless a drag or a controlled
change moved the sheet in between. The gate is a measured nonzero inset, never focus alone: a
desktop click into an input expands nothing.
Top, left, and right sheets are pinned to their edge, so the panel itself never moves for the
keyboard. Their content still can, though: the same inset becomes bottom clearance on
[data-scrollsheet-body] plus scroll-padding on the panel, so a focused field near the
bottom of a side drawer scrolls clear instead of sitting under the keyboard.
Full-height content
<Sheet.Content fill> stretches the body to the panel (flex column) so a fixed header plus an
independently scrolling list needs no CSS chain of your own:
<Sheet.Content fill className="sheet">
<div className="search">{/* fixed */}</div>
<div className="results">{/* flex: 1; min-height: 0; overflow-y: auto */}</div>
</Sheet.Content>
The list then sizes to whatever the sheet has left, including when the keyboard caps the panel
at the visual viewport. fill is off by default because it changes how the content detent
measures: with it on, the detent and content-morph both switch from the body’s own box to its
first child, since a stretched body no longer reflects content height on its own.
Doing it by hand instead, same result:
.sheet { display: flex; flex-direction: column; }
.sheet [data-scrollsheet-body] { display: flex; flex-direction: column; flex: 1; min-height: 0; }
.sheet .results { flex: 1; min-height: 0; overflow-y: auto; }
[data-scrollsheet-body] is the wrapper the library already renders around your content; fill
just sets these two rules for you off one attribute. The map search example predates the prop
and still runs this CSS version.
Desktop drag
button, a, input, textarea, select, label, [contenteditable], [data-scrollsheet-no-drag]
pointerType: 'mouse' can grab the panel anywhere and drag it; the last ~100ms of pointer
samples project velocity on release, so a flick lands past the cursor. Touch and pen skip this
path and keep riding native scroll-snap. The elements above opt out automatically; add
data-scrollsheet-no-drag to anything else that needs the same treatment.
Trackpad and wheel input ride native scroll-snap too, not this drag path, but a single-detent
sheet only has two snap stops (0 and the one detent), so an ambiguous wheel gesture used to land
wherever the browser’s own snap heuristic put it, ignoring closeThreshold. A short wheel-idle
correction pass now compares the gesture against closeThreshold once it settles and only
overrides when the two disagree, so closeThreshold governs a wheel dismissal the same way it
governs a drag. Multi-detent sheets need no correction: enough stops between them for native
snap to land correctly on its own.
Nested and morph
<Sheet.Root>
<Sheet.Trigger>Open</Sheet.Trigger>
<Sheet.Content className="sheet">
{/* a Sheet.Root rendered in here just works */}
<Sheet.Root>
<Sheet.Trigger>Open child</Sheet.Trigger>
<Sheet.Content className="sheet">Child sheet</Sheet.Content>
</Sheet.Root>
</Sheet.Content>
</Sheet.Root>
Nesting is automatic: a Sheet.Root rendered inside another sheet’s content registers with
the parent, which recedes and dims while the child is open. Height morphs the same way: the
sheet springs to fit new content whenever it changes inside an open sheet. For a cross-fade
between views instead of a resize, wrap the state update in document.startViewTransition
(feature-detected, falls back to an instant swap).
Scrollable content inside a sheet
<Sheet.Content>
<div data-scrollsheet-nested-scroll style={{ overflowY: 'auto', maxHeight: 300 }}>
{/* a long list */}
</div>
</Sheet.Content>
Mark an inner scroller with data-scrollsheet-nested-scroll and the classic conflict resolves
itself: swiping scrolls the list, and once the list hits its top, the same swipe continues as
sheet travel. Works under handleOnly and disableDrag too; the handoff only widens the drag
surface while a touch that started inside the marked element is live. Any number of elements can
carry the attribute, including ones added or removed while the sheet is open.
The marked element gets the panel’s own overlay scrollbar treatment too, controlled by the same
scrollbar prop on Sheet.Root: 'overlay' (the default) hides the native scrollbar and shows a
thin auto-hiding thumb, 'hidden' hides it with no thumb, 'native' leaves it alone. Under
'overlay', scrollsheet sets position: relative on the marked element to anchor the thumb;
overriding it back to static breaks the thumb. The other two modes leave position alone.
Not supported inside shadow roots (scroll does not compose across the boundary).
Side panels
<Sheet.Root side="right" detents={['340px']}>
<Sheet.Trigger>Notifications</Sheet.Trigger>
<Sheet.Content className="sheet">{/* ... */}</Sheet.Content>
</Sheet.Root>
side anchors the sheet to 'bottom' (the default), 'top', 'left', or 'right'. Left and
right sheets scroll on the x axis instead of y, so detents are widths, not heights. The handle’s
arrow-key axis flips to Left/Right for horizontal sides automatically, and the panel’s
border-radius/shadow default rounds the lead edge (the one opposite the anchored side).
Non-modal
<Sheet.Root modal={false}>
modal={false} keeps the page behind fully interactive: no backdrop, no focus trap, no
inert-ing the rest of the page. It renders on a <div popover="manual"> (top layer,
non-blocking) where the Popover API is available, falling back to a non-modal <dialog>
(.show()) otherwise. Good for a mini player, a map overlay, or a toast, anywhere the page
underneath should keep working while the sheet is up.
Toasts
const [open, setOpen] = useState(false);
useEffect(() => {
if (!open) return;
const timer = setTimeout(() => setOpen(false), 4000);
return () => clearTimeout(timer);
}, [open]);
<Sheet.Root modal={false} open={open} onOpenChange={setOpen} detents={['content']}>
A toast is just modal={false} plus a plain timer closing it, the same controlled open any
other sheet uses. Nothing toast-specific in the library, no separate primitive.
Migrating an existing Sonner integration instead of writing this by hand?
- import { toast, Toaster } from 'sonner';
+ import { toast, Toaster } from 'scrollsheet';
scrollsheet’s Sonner compat layer is a drop-in for toast()/Toaster that runs on this same
primitive. See Migrate from Sonner below.
Page transitions
<div data-scrollsheet-background>{/* your page content */}</div>
<Sheet.Root backgroundEffect="scale" detents={['full']}>
Mark your page wrapper data-scrollsheet-background, then backgroundEffect="scale" scales,
insets, and rounds it in sync with the sheet’s own travel, an iOS-style card transition. Paired
with a 'full' detent, a sheet becomes a full-screen page that pushes the page behind it back
like a stack. 'parallax' shifts the wrapper instead of scaling it. Read
--scrollsheet-stack-progress on a parent sheet’s own panel for the equivalent effect between
nested sheets.
Mark the wrapper and a full-height bottom sheet gets 'scale' on its own, with no prop: that is
how the platform presents a sheet covering the screen. The default only fires when the sheet was
opened from inside that wrapper, so a page hosting several independent sheets never moves the
wrong one. Desktop drawers dock beside the page and leave it alone, and 'none' opts out.
One caveat: the wrapper’s transform makes it the containing block for any position: fixed
descendant, so a fixed element inside it (a floating action button, a cookie bar) gets scaled
and shifted along with the page. Keep such elements outside the marked wrapper.
backgroundRef targets the wrapper directly instead of the data-scrollsheet-background query,
for a wrapper you’d rather not stamp an attribute onto, or two sheets on the same page whose
document-wide query could otherwise collide:
const pageRef = useRef<HTMLDivElement>(null);
<div ref={pageRef}>{/* your page content, no attribute needed */}</div>
<Sheet.Root backgroundEffect="scale" backgroundRef={pageRef} detents={['full']}>
It skips the marker lookup and its ownership check entirely: an explicit ref is unambiguous
about which element it means. The implicit default (no backgroundRef, no explicit
backgroundEffect) is unchanged.
Using scrollsheet with Motion
scrollsheet’s own motion lives in scrollsheet/motion (spring, WAAPI, the scroll tween) and never
reaches for a separate animation library. onTravel and --scrollsheet-progress are the
integration boundary if you already animate with something else: data comes out of the sheet,
no engine goes in.
import { useMotionValue, useTransform, motion } from 'motion/react';
const progress = useMotionValue(0);
const scale = useTransform(progress, [0, 1], [0.9, 1]);
<Sheet.Root onTravel={(_px, p) => progress.set(p)} detents={['full']}>
<Sheet.Content>
<motion.div style={{ scale }}>{/* rides the sheet's own travel */}</motion.div>
</Sheet.Content>
</Sheet.Root>
The same shape works with any library that takes a plain number: read progress off onTravel’s
second argument, or drive off --scrollsheet-progress directly for something CSS-only. scrollsheet
never imports Motion and Motion never reaches into scrollsheet; the sheet just publishes a number,
once a frame, and stays out of the rest.
Migrate from vaul
- import { Drawer } from 'vaul';
+ import { Drawer } from 'scrollsheet';
Drop-in: same Drawer.* namespace, translated onto Sheet.*. Nesting is automatic: no
separate NestedRoot.
| vaul API | Maps to |
|---|---|
snapPoints, activeSnapPoint, direction (all 4 edges), modal={false}, onAnimationEnd, onClose, fadeFromIndex, handleOnly, snapToSequentialPoint, onRelease, preventCycle (on Drawer.Handle), asChild |
A real feature, 1:1 |
shouldScaleBackground |
backgroundEffect; your [data-vaul-drawer-wrapper] is picked up automatically |
closeThreshold |
A real feature; the adapter converts conventions (vaul counts the drag away, scrollsheet counts what remains), so vaul’s 0.25 default keeps its feel. Only live when snapPoints is unset, matching vaul |
onOpenStart / onCloseStart |
onOpenChange (fires at the state flip) |
onOpenEnd / onCloseEnd |
onOpenChangeComplete(open) (fires once the transition visually finishes) |
One deliberate accuracy divergence: onRelease’s second argument is the real open/closed
outcome here (true only when the sheet stays open). Real vaul always passes true when
snapPoints is set, even on a release that dismisses the drawer.
data-vaul-drawer, -direction, and -snap-points still land on Drawer.Content;
data-vaul-handle on Drawer.Handle, so CSS written against vaul’s selectors keeps matching.
Title, Description, and Close get no vaul-branded attribute.
Props that existed to fight the page (scroll-lock, portal containers, manual keyboard handling,
fixed) are unnecessary here and warn once in dev. Radix Dialog.Content-only props with no
equivalent (onPointerDownOutside, onOpenAutoFocus, onEscapeKeyDown, onCloseAutoFocus,
onInteractOutside, onFocusOutside, forceMount) are stripped before they reach the DOM.
Drawer.Overlay renders nothing. The backdrop is automatic.
Migrate from Sonner
- import { toast, Toaster } from 'sonner';
+ import { toast, Toaster } from 'scrollsheet';
Drop-in: same toast, useSonner, Toaster exports, built on one non-modal Sheet.Root per
<Toaster> instead of Sonner’s own DOM stack. toast(), .success(), .error(), .info(),
.warning(), .loading(), .message(), .custom(jsx), .promise(), .dismiss(id?) all work
before or after any <Toaster> mounts, backed by the same module-level queue. A repeated id
updates a toast in place instead of stacking a second one. toasterId routes independently: a
toast() call with a matching toasterId shows only on that instance, everything else on the
default un-keyed one.
The stack shows a collapsed front card with ghost cards behind it, expanding to the full list on
hover or focus, same as real Sonner. Auto-dismiss uses the toast’s own duration, or the
Toaster’s. Bug fixed along the way: Sheet.Content moves focus onto the panel by default,
which would steal focus every time a toast fired. This shim captures and restores focus around
the mount so it never does, something plain Sonner never had to solve since it never took focus.
<Toaster visibleToasts={5}> caps how many toasts render at once, default 3 like Sonner’s own.
Past the cap, the oldest dismissible toast evicts (onDismiss fires, same as a manual close) to
make room for the new arrival. A dismissible: false toast holds its slot instead: if every
visible slot is pinned that way, a new toast queues untouched in the store until one clears
itself. visibleToasts clamps to a minimum of 1 (0 or a negative number still shows one toast,
not zero), so a <Toaster visibleToasts={1}> whose single slot holds a non-dismissible toast
queues every later arrival for as long as that one stays up.
Enter/exit, per-row swipe-to-dismiss (drag past 45px), and the crossfade between the collapsed
and expanded stack are CSS transitions, not a JS animation loop, adapted from Sonner’s own
approach. prefers-reduced-motion: reduce collapses every duration to zero and turns the
transitions off outright, the same contract the core primitive already keeps.
| v1 gap | Behavior |
|---|---|
position |
Only top-right / bottom-right render (Sonner’s own default); the other four warn once and fall back to bottom-right |
theme |
Static light card; dark and system warn once instead of silently no-op |
richColors |
Not implemented, warns once |
toast.dismiss() with no id |
Clears every toast across every mounted <Toaster>, matching real Sonner exactly |
useSonner() |
Returns every toast across every toaster, unfiltered; <Toaster toasterId> filters internally |
Auto-injected styles land in document.head on first render, same as the core primitives.
Import scrollsheet/sonner.css yourself to control load timing instead.
Shadow DOM
The auto-injected styles land in document.head, which a shadow root doesn’t see. Call
injectStylesInto once per shadow root, before any Sheet.Content inside it opens:
import { injectStylesInto } from 'scrollsheet';
injectStylesInto(shadowRoot);
It adopts the core stylesheet into the root via adoptedStyleSheets, so a trigger and its
surrounding markup living behind shadow encapsulation get properly scoped styles instead of
reaching across the boundary for document.head’s tag. On an engine with no constructable
stylesheets (Safari < 16.4) it falls back to a <style> tag appended inside the root itself,
still scoped, just not adopted. The <dialog> itself still portals to document.body regardless:
this only scopes the trigger/surrounding markup’s stylesheet, not where the sheet renders, since a
top-layer dialog is structurally a document-level thing.
injectToastStylesInto is a sibling export for the toast stylesheet, not folded into
injectStylesInto: it targets separate CSS that a core-only consumer’s bundle tree-shakes away
unless something actually imports it:
import { injectToastStylesInto } from 'scrollsheet';
injectToastStylesInto(shadowRoot);
Both take an optional nonce, for a CSP style-src 'nonce-...' policy. It only does anything on
the <style>-tag fallback path: a nonce authorizes an inline style element, not a constructed
CSSStyleSheet, so the adoptedStyleSheets path has nothing for it to gate.
Framework support
scrollsheet is SSR-safe by design, not by special-casing. The dialog only exists once JS runs on
the client: it’s gated behind a mount flag that starts false and flips in a useEffect, so the
server render and the first client render before hydration finishes produce identical markup, no
dialog, just your trigger. There’s no window/document access at module scope anywhere in the
package.
| Framework | Works out of the box | Notes |
|---|---|---|
| Next.js App Router | Yes | "use client" is already on every file that needs it, including the dist chunk boundary. |
| Next.js Pages Router | Yes | No RSC boundary to worry about; "use client" is inert but harmless. |
| Remix / React Router v7 | Yes | Same SSR + hydration shape scrollsheet already handles. |
| Astro | Yes | See the caveat below. |
| Vite (SPA, no SSR) | Yes | renderToString never runs, so this is the simplest case. |
Astro caveat: an island only hydrates if you give it a client:* directive (client:load,
client:idle, client:visible, etc.). Put one on whatever component renders your
Sheet.Trigger, same as any interactive Astro component, nothing scrollsheet-specific about it.
Which directive you pick doesn’t matter to scrollsheet: the mount gate works identically whether
hydration happens immediately or is deferred.
Desktop presentation
Above 768px the defaults change shape: bottom sheets dock to the right edge as a drawer-width column (640px max, 24px margin), and side sheets square off their lead edge. Below 768px, sheets stay full-bleed mobile style.
All of it is default styling, not behavior. --scrollsheet-max-inline and --scrollsheet-desktop-margin tune the column: set --scrollsheet-desktop-margin: 0px with --scrollsheet-max-inline: none to restore full-bleed. --scrollsheet-radius reclaims the rounded edge on desktop side sheets.
One more lever, all viewports: --scrollsheet-travel: none on your content class switches the enter/exit travel off: the sheet appears and disappears in place, phase callbacks still firing. For sheets whose entrance something else carries: the gallery’s Lightbox sets it only when the browser can morph the thumbnail into the viewer with the View Transitions API, so the morph is the one entrance story.
Which example shows which prop
Every Root prop, mapped to the live example that exercises it on the homepage gallery.
| Prop | Example |
|---|---|
detents, activeDetent, onActiveDetentChange |
Snap heights, Ride tracker |
sequentialDetents, largestUndimmedDetent, onRelease, onOpenChangeComplete, themeColorDimming, closeThreshold |
Ride tracker |
actionsRef (open / close / snapTo) |
Ride tracker |
onTravel |
Ride tracker, the hero sheet |
modal |
Music player, Map search, Toast, Alert banner |
side |
Sidebar (left), Notifications (right), Alert banner (top) |
dismissible |
Cart and checkout, Non-dismissible confirm |
disableDrag |
Photo viewer |
handleOnly |
No handle (its inverse; handleOnly itself is covered in e2e) |
backgroundEffect |
Page transition recipe below |
scrollbar |
Comments |
open, defaultOpen, onOpenChange |
Toast, Alert banner, every controlled example |
nonce |
CSP note in Styling |
Accessibility
Modal focus containment comes from the platform’s own showModal(), not a JS focus trap: opening
a sheet moves focus onto the panel container so mobile keyboards don’t pop unasked, and closing it
returns focus to the trigger. Put a native autofocus on a field inside Sheet.Content when you
want the keyboard to come up right away instead.
With two or more detents, Sheet.Handle exposes role="slider" with the matching
aria-valuemin/-valuemax/-valuenow/-valuetext, and moves between stops with Up/Down (or
Left/Right on side sheets), Home, and End. Sheet.Title and Sheet.Description wire
aria-labelledby/aria-describedby onto the dialog automatically whenever you render them.
Short version in the FAQ: How accessible is it?.
Browser support
| Browser | Spring easing | Compositor-driven backdrop dim + --scrollsheet-progress |
|---|---|---|
| Chrome / Edge | 113+ | 115+ |
| Safari | 17.2+ | 26+ |
| Firefox | 112+ | behind a flag, see below |
Safari 16.4–17.1 parses <dialog> but not CSS linear(), so the spring falls back to a plain
ease-out at the same duration. scrollend falls back to a ~120ms debounced scroll-silence check
where unsupported.
The backdrop dim and --scrollsheet-progress run as CSS scroll-driven animations
(animation-timeline: scroll()) on engines that support them, off the main thread entirely,
feature-detected once and stamped data-scrollsheet-sda on the dialog. Firefox has
animation-timeline: scroll() implemented but still ships it behind a flag as of mid-2026, and
Safari 17.2 through 25 has the spring but not scroll-driven animations yet; on both, and on every
other engine below the compositor floor, the same numbers still update every frame from
onTravel’s own JS write instead, same values, main thread rather than the compositor. Two
pieces stay JS-driven on every engine regardless: a stacked sheet’s parent-panel recede (the
parent panel lives in a sibling top-layer <dialog>, not a descendant the track’s timeline can
reach) and backgroundEffect (its target is an arbitrary page element outside the dialog
entirely).
Where dialog.closedBy exists (Chrome/Edge 134+, Firefox 141+), a modal sheet sets closedby to
closerequest when escapeDismissible is on, none when it’s off, so Esc and the Android back
gesture route through the platform’s own close-request machinery instead of scrollsheet’s manual
handling. Never closedby="any": .scrollsheet-dialog always covers the full viewport, so there’s
no “outside the dialog” region for a native light-dismiss to detect, and layering one on top of the
library’s own backdrop-click handling would only risk a double-fire for no gain. Engines without
closedBy (Safari, as of this writing) keep the existing manual Esc/backdrop dismissal, unchanged.
Non-modal sheets (modal={false}) don’t get a platform close watcher for free the way a modal
showModal() dialog does, so a dismissible one creates its own via CloseWatcher (Chrome 144+)
so Android back closes it instead of navigating the page. Touch-primary devices only: Esc is also
a platform close signal wherever CloseWatcher exists, and non-modal Esc is deliberately scoped
to focus inside the sheet, so a desktop watcher would steal the page’s Esc. Real-device
verification on that path is still pending.
Without <dialog>
| ~4% of browsers (Opera Mini, old in-app WebViews, iOS ≤15.3) | |
|---|---|
| You get | A fixed-position modal: backdrop, tap-to-close, Escape, focus moved into the panel on open and restored to the trigger on close, body scroll locked. Panel sizes to content up to 85% of the viewport, then scrolls internally |
| Gone | Detents, drag, the enter/exit animation, the top layer (panel portals to <body> with a max z-index instead of a guarantee), the native focus trap. Sheet.Handle renders nothing |
| Still works | Every --scrollsheet-* theming variable (-bg, -fg, -radius, -shadow, -backdrop, safe-area) resolves the same way. isSupported() reports which path a browser takes |
The trade against libraries that portal a plain <div> (vaul, react-modal-sheet): their full
gesture experience reaches these browsers and ours does not. The bet is that the top layer is
worth more on the 96% than the gestures are on the 4%.
API
Sheet.Root
| Prop | Type | Default |
|---|---|---|
open |
boolean |
controlled |
defaultOpen |
boolean |
false |
onOpenChange |
(open: boolean) => void |
|
onOpenChangeComplete |
(open: boolean) => void |
fires when the transition visually finishes |
actionsRef |
React.Ref<SheetActions> |
open() / close() / snapTo(detent) |
detents |
readonly DetentSpec[] |
['content'] |
activeDetent |
DetentSpec |
controlled |
onActiveDetentChange |
(detent: DetentSpec) => void |
|
dismissible |
boolean |
true |
escapeDismissible |
boolean |
dismissible |
backdropDismissible |
boolean |
dismissible |
nonce |
string |
|
themeColorDimming |
boolean |
false |
onTravel |
(revealedPx: number, progress: number, info: TravelInfo) => void |
|
side |
'bottom' | 'top' | 'left' | 'right' |
'bottom' |
modal |
boolean |
true |
backgroundEffect |
'scale' | 'parallax' | 'none' |
'scale' for a full-height mobile bottom sheet opened from inside the marked wrapper, else off |
backgroundRef |
React.RefObject<HTMLElement | null> |
targets backgroundEffect directly, skipping the data-scrollsheet-background query |
scrollbar |
'overlay' | 'hidden' | 'native' |
'overlay' |
largestUndimmedDetent |
DetentSpec |
dims across the full range |
handleOnly |
boolean |
false |
disableDrag |
boolean |
false |
sequentialDetents |
boolean |
false |
closeThreshold |
number (0-1) |
0.5 |
keyboardExpands |
boolean |
false |
onRelease |
(event: PointerEvent, willRemainOpen: boolean) => void |
Sheet.Content styling hooks
| Attribute / property | Where | Values |
|---|---|---|
data-scrollsheet-state |
<dialog> |
pre | opening | open | closing |
data-scrollsheet-side |
<dialog> |
bottom | top | left | right |
data-scrollsheet-modal |
<dialog> |
"false" when modal={false}, absent otherwise |
data-scrollsheet-scrollbar |
<dialog> |
overlay | hidden | native |
data-scrollsheet-at-max |
<dialog> |
present at tallest detent |
data-scrollsheet-behind |
<dialog> |
present while a child sheet is open |
data-scrollsheet-dragging |
<dialog> |
present during a desktop drag |
data-scrollsheet-handle-only |
<dialog> |
present when handleOnly |
data-scrollsheet-disable-drag |
<dialog> |
present when disableDrag |
data-scrollsheet-detached |
<dialog> |
present once --scrollsheet-inset-bottom is set (the inset-card recipe) |
data-scrollsheet-sda |
<dialog> |
present once CSS scroll-driven animations own the backdrop dim + --scrollsheet-progress, see Browser support |
data-scrollsheet-fill |
panel and body | present when fill |
data-scrollsheet-no-drag |
anywhere | opts an element out of desktop drag |
data-scrollsheet-backdrop |
backdrop element | present on the built-in backdrop, for restyling it |
--scrollsheet-progress |
backdrop element | 0–1, same value as onTravel. Set on the backdrop, not the dialog, so read it there (siblings don’t inherit) |
--scrollsheet-stack-progress |
parent panel | 0–1, set while a child sheet is open. Read it in your own CSS to replace the built-in scale + dim recede with something else |
--scrollsheet-max-detent |
<dialog> |
tallest resolved detent, px |
--scrollsheet-keyboard |
<dialog> |
on-screen-keyboard inset, px |
--scrollsheet-backdrop |
panel (set by you) | any CSS color |
--scrollsheet-handle / -handle-hover |
panel (set by you) | handle pill color, resting / hover |
--scrollsheet-safe-area |
panel (set by you) | usually env(safe-area-inset-bottom) |
--scrollsheet-radius |
panel (set by you) | corner radius, default 16px |
--scrollsheet-bg |
panel (set by you) | panel background color |
--scrollsheet-shadow |
panel (set by you) | panel box-shadow |
--scrollsheet-inset-x / -top / -bottom / -left / -right / -y |
panel (set by you) | px offsets for the “inset card” recipe (-x/-y are the cross-axis inset for bottom/top and left/right sheets) |
--scrollsheet-scrollbar-width / -color |
panel (set by you) | overlay scrollbar thumb, default 4px; also styles nested-scroller thumbs |
data-scrollsheet-scrollbar |
injected thumb | marks the library-created .scrollsheet-scrollbar div on a nested scroller |
--scrollsheet-focus-ring / -width |
handle (set by you) | keyboard focus ring on Sheet.Handle |
Other exports
| Export | Extends / type |
|---|---|
Sheet.Content |
<div>, the <dialog>, backdrop, scroll track, and panel together. aria-label sets the accessible name when there’s no Sheet.Title. fill stretches the body to the panel for full-height inner-scroll layouts (see Full-height content) |
Sheet.Trigger |
<button>, opens on click. asChild renders your own element instead |
Sheet.Handle |
<button>, drag grip; arrows/Home/End move detents, announces as a slider with 2+ detents. asChild supported |
Sheet.Title / Sheet.Description |
<h2> / <p>, auto-wires aria-labelledby / aria-describedby |
Sheet.Close |
<button>, closes on click. asChild supported |
DetentSpec |
'full' | 'medium' | 'content' | number | ${number}px |
Side |
'bottom' | 'top' | 'left' | 'right' |
SheetActions |
{ open(): void; close(): void; snapTo(detent: DetentSpec): void } |
spring(config?) |
{ stiffness, damping, mass, velocity, restDelta } → { easing, durationMs } |
isSupported() |
() => boolean, false on the ~4% of browsers with no <dialog>, where the sheet falls back to a plain modal (see Without <dialog>) |