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 01, same value as onTravel. Set on the backdrop, not the dialog, so read it there (siblings don’t inherit)
--scrollsheet-stack-progress parent panel 01, 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>)