API reference
Anatomy
Compose a sheet by passing slots to open() — each slot fills one part.
import {createSheetCore} from 'sheet-view'
const sheets = createSheetCore()
sheets.open({
title: 'Title', // header — title + close button (or use headerSlot)
content: (ctx) => node, // content — the scrollable body
footer: (ctx) => node, // footer — pinned actions (omit → no footer)
overlaySlot: (ctx) => node, // overlay — decorations that spill past the card edge
})
// every slot receives ctx = { close, update }import {SheetHost, sheets} from 'sheet-view/react'
// 1. Mount the host once, near your app root:
function App() {
return (
<>
<YourApp />
<SheetHost />
</>
)
}
// 2. Open sheets imperatively — every slot is a render prop:
sheets.open({
title: 'Title', // header — title + close button
content: ({close}) => <Body />, // content — the scrollable body
footer: ({close}) => <Actions />, // footer — pinned actions
overlaySlot: () => <Badge />, // overlay — decorations past the card edge
})Those slots render into this DOM — target the data-sheet-part hooks (or the .sv-sheet__* classes) for styling:
<!-- data-sheet-stack is absent on the top sheet; "covered" / "buried" / "hidden" below it -->
<dialog class="sv-sheet" data-sheet-state="open">
<div data-sheet-part="backdrop"></div> <!-- the dim behind the card -->
<!-- scroll / spacer / panel: the scroll-snap wrappers that power drag-to-close -->
<div class="sv-sheet__card"> <!-- the visible surface -->
<div class="sv-sheet__handle"></div> <!-- mobile drag pill -->
<div data-sheet-part="header">…</div> <!-- title + close, or your headerSlot -->
<div data-sheet-part="content">…</div> <!-- the scrollable body -->
<div data-sheet-part="footer">…</div> <!-- pinned actions -->
<div data-sheet-part="overlay">…</div> <!-- decorations, free to overflow -->
<div data-sheet-part="anchor-layer">…</div> <!-- layers.anchored: dropdowns -->
<div data-sheet-part="scrim"></div> <!-- the card's own dim, when covered -->
</div>
<div data-sheet-part="toplayer"> <!-- above the card, viewport-fixed -->
<div data-sheet-part="viewport-layer">…</div> <!-- layers.viewport: toasts -->
</div>
</dialog>The default header expands to icon + title + close:
<div data-sheet-part="default-header">
<span data-sheet-part="icon">…</span> <!-- your `icon`, collapsed while empty -->
<h2 data-sheet-part="title">…</h2>
<button data-sheet-part="close" aria-label="Close">
<span data-sheet-part="close-icon">…</span> <!-- your `closeIcon`; × while empty -->
</button>
</div>The two layers are where app-authored popovers go — see Popovers.
createSheetCore(options?)
Creates a framework-agnostic core. Returns {open, closeAll, subscribe, getSnapshot}. sheet-view also exports sheetCore — a shared singleton core, for an app that only needs one stack. The React sheets facade is bound to it.
| Option | Type | Default | Notes |
|---|---|---|---|
closeMs | number | 517 | Exit duration for a button/backdrop/Escape/programmatic close — which always travels a whole card height. |
dragCloseMs | number | 517 | Release duration — the same however far the card still has to go, so the speed is proportional to the distance. Covers the drag-commit exit and the snap-back; a flick differs only in the curve it rides. |
enterMs | number | 507 | Mobile entrance duration — the JS mirror of --sheet-enter-duration. |
openSettleMs | number | enterMs | Delay after open before drag-to-close arms. |
breakpoint | number | 768 | Viewport width (px) below which the mobile slide-up layout applies. |
zoomLock | boolean | false | Pins maximum-scale=1 while open to block iOS focus zoom. Off by default — a WCAG 1.4.4 trade-off. |
closeLabel | string | 'Close' | Default accessible label for close buttons in this instance. |
closeMs is the whole exit: the card's animation, the promotion of the sheet underneath, and the delay before the DOM is removed all read it, so nothing is left to drift out of step with anything else.
Every release takes the same time, whatever distance is left — so the speed is proportional to the distance, not constant. Scaling the duration with the travel instead lands a near-threshold snap-back in about 200 ms against a reference ~470 ms, which reads as the sheet snapping rather than settling.
enterMs retunes the entrance from one number: it writes the private --_sheet-enter-ms that --sheet-enter-duration falls back to, and it defaults openSettleMs to the same value, so the card slide, the dim fade, and the moment the drag arms stay in step. Setting the CSS token instead still wins over enterMs — see Theming → Motion.
open(props)
Returns a stable SheetHandle. All props are optional.
| Prop | Type | Notes |
|---|---|---|
title | string | Default-header title (ignored when headerSlot is set). Omit it and no default header is built at all — and so no close button. |
icon | SheetSlot | Leading glyph before the title. Requires title; ignored with headerSlot. Not aria-hidden — mark a decorative icon yourself. |
size | 'sm' | 'md' | 'lg' | 'xl' | Mobile height + desktop width. Default 'lg'. Both are tokens — see Sizing. |
content | SheetSlot | The scrollable body. |
footer | SheetSlot | Pinned footer; collapses when empty. |
headerSlot | SheetSlot | Replaces the default title/close row. |
overlaySlot | SheetSlot | Decorations anchored to the card, free to extend past edges. |
focusOnOpen | boolean | Autofocus a field; keyboard-safe entrance on mobile. |
ariaLabel | string | Accessible name. Without it the title names the dialog — via aria-labelledby for the default header, or as aria-label when headerSlot owns the row. |
closeLabel | string | Accessible label for the close button. Default 'Close'. |
closeIcon | SheetSlot | Glyph inside the close button, in place of the default ×. The button stays library-owned — its name still comes from closeLabel. |
closeDisabled | boolean | Blocks X/backdrop/drag/Escape; fires onCloseAttempt. Chromium closes natively on a second consecutive Escape, see the notes. |
closeHidden | boolean | Omits the default close button (a forced sheet). |
cardClassName | string | Class(es) on the card element. |
className | string | Class(es) on the root dialog. |
style | Record<string,string> | Inline styles/tokens on the root — reaches every part. |
key | string | Dedupe scope for a singleton sheet. |
strategy | 'reuse' | 'replace' | 'update' | With key. Default 'reuse'. |
onClose | () => void | Fired when a close is requested (not for a blocked/silent close). |
onCloseAttempt | () => void | Fired when a close is blocked by closeDisabled. |
onExited | () => void | Fired after the exit animation, before DOM removal. |
SheetSlot = Node | string | null | undefined | ((ctx) => Node | string | null), where ctx is {close, update}.
SheetHandle
interface SheetHandle {
id: number
close: () => void
update: (next: Partial<SheetOpenProps>) => void // merges next props into the live sheet
slots: {header; icon; closeIcon; content; footer; overlay; toplayer} // nodes to portal into
layers: {anchored; viewport} // mount points for dropdowns / toasts
phase: () => 'entering' | 'settled' | 'closing'
onPhase: (listener: (phase) => void) => () => void // change-only; returns an unsubscribe
}layers and phase are the popover surface: where to mount a panel, and when viewport-coordinate measurements are stable. See Popovers.
React adapter
sheet-view/react wraps the core in an imperative facade whose slots accept ReactNode. The adapter ships with 'use client', so it works in an RSC / App Router app as-is.
import {SheetHost, SheetPortal, sheets, createSheets} from 'sheet-view/react'| Export | What it is |
|---|---|
sheets | the default facade, bound to the shared sheetCore. |
createSheets(core?) | build your own facade (defaults to a fresh createSheetCore()). |
<SheetHost/> | mount once; portals slot content into open sheets. |
<SheetPortal/> | mount popovers (dropdowns, toasts) in the right layer. |
useSheetLayout() | this sheet's card / scrollers / layers / motion phase. |
useSheetPortalTarget() | the node to portal into — never null. For container= props. |
sheets / createSheets(core?) → Sheets
| Method | Notes |
|---|---|
open(props?) | opens a sheet; returns a SheetPublicHandle. Slots widen to ReactSlot. |
closeAll() | starts closing every open sheet. |
hasLocked() | true while any open sheet has closeDisabled — for a beforeunload guard. |
ReactSlot = ReactNode | ((ctx) => ReactNode), where ctx is {close, update} — the same context the vanilla core hands its slot factories.
SheetPublicHandle
interface SheetPublicHandle {
id: number
close: () => void
update: (next: Partial<SheetReactProps>) => void
}Unlike the vanilla SheetHandle, it has no slots — the adapter owns the portals, so you never reach for the slot nodes yourself.
<SheetHost instance?={sheets} onSlotError?={fn} />
Mount once near the app root. Renders the facade's open sheets, portaling each slot's ReactNode into the core's DOM. instance defaults to the shared sheets; pass a createSheets(...) facade to render an isolated one.
Each slot is wrapped in its own error boundary. A slot that throws renders nothing and logs to console.error, while the sheet and your app stay mounted — per-slot, so a crashing content still leaves the header's close button usable. onSlotError(error, info, slot) forwards it to your reporter; it does not render a fallback (that is app-level policy, and the failed slot is simply empty).
Popovers
<SheetPortal>, useSheetLayout() and useSheetPortalTarget() place app-authored dropdowns, menus, pickers and toasts inside an open sheet. The library ships the mount points, the non-clipping guarantee, a paint-order contract and a motion-phase signal — positioning stays yours, so your floating-ui / Popper / Radix code keeps working.
Full contract, positioning rules and pitfalls: Popovers.
React specifics
titlewith a customheaderSlot. PassingheaderSlotmakes the adapter droptitlefrom the rendered row — your header fully replaces the default title/close row, so the two can't collide. The name is not dropped, though:titlebecomes the dialog'saria-label, so the sheet still has an accessible name. Keep it matching the heading you render, or passariaLabel— a visible name that differs from the accessible one is a WCAG 2.5.3 (label in name) failure.- Wanting only a glyph? Use
iconfor a leading one andcloseIconfor the close button — both takeReactNode, so alucide-react/ FontAwesome component goes straight in. Reach forheaderSlotonly to replace the row itself: it costs you the close button too, and its label, itsaria-disabledbehaviour and its 44×44 hit target all become yours to rebuild.
Testing
import {installDialogShim} from 'sheet-view/testing'
interface InstallDialogShimOptions {
cancelOnEscape?: boolean // translate an Escape keydown into `cancel`. Default false.
}
interface DialogShim {
installed: readonly ('open' | 'showModal' | 'show' | 'close')[] // what it patched
restore: () => void
}
function installDialogShim(options?: InstallDialogShimOptions): DialogShimjsdom ships HTMLDialogElement without showModal(), show() or close(), so any test that opens a sheet throws showModal is not a function. Call the shim once in your setup file:
// vitest.setup.js
import {installDialogShim} from 'sheet-view/testing'
installDialogShim()Idempotent, guarded per member, and a silent no-op in a real browser — so the same setup file works under vitest browser mode or Playwright too.
Not emulated, deliberately: the top layer, focus trapping, inert, focus restoration, requestClose(). jsdom can't host those, and faking them produces tests that pass against a fiction. close() dispatches its event synchronously (browsers queue it), which keeps teardown ordering deterministic.
Escape isn't translated either — dispatch cancel, which is what the UA does:
fireEvent(dialog, new Event('cancel', {cancelable: true}))Opt in with installDialogShim({cancelOnEscape: true}) if you'd rather press Escape. It treats the last dialog[open] as topmost, which is an approximation — jsdom has no top-layer stack. Call restore() if your setup file is shared across suites without isolation, or its document listener leaks.
Don't hand-roll it
A shim that patches showModal() but not close() looks fine until the sheet closes: the core releases its scroll lock on the native close event, so without it the page stays frozen for the rest of the file.
Notes & limitations
Client-only
open().open()touchesdocument; call it in the browser, not during SSR.<SheetHost>itself is SSR-safe (renders nothing on the server).The scroll lock is
data-sheet-scroll-lockon<html>. While any sheet is open the core sets this attribute (value = number of open sheets) andbase.cssapplies the actualoverflow: clip/ body pin. Nothing is ever written tohtml.style.overfloworbody.style.overflow, so third-party scroll locks (Headless UI, body-scroll-lock, …) that save/restore those inline registers compose with the sheet in any open/close order. The attribute is a stable styling hook; thedata-sheet-scroll-gap/data-sheet-scroll-pinattributes and--_sheet-lock-*properties beside it are internal.A stack renders a bounded deck. The core marks each dialog root with
data-sheet-stack: absent on the top sheet,"covered"directly under it,"buried"one deeper (parked behind the top card, still painting — a drag on the top sheet slides it out in real time),"hidden"beyond that (pixel-identical to buried forever, so it stops painting). Two more stable hooks ride along:data-sheet-nested(opened over another sheet — such a card is slightly shorter, which is what leaves the covered card's strip visible) anddata-sheet-recede(the card holds the receded pose: full-height sheets recede under a full-height sheet;sm/mdstacks keep their natural rects and only the dims mark the depth). The pose is mobile-only. On desktop every card in a stack keeps its natural rect and sits exactly on the one below, marked by the dim alone: a centred dialog has no bottom edge to peek above. The attribute is still written at both widths so you can style your own desktop pose against it. A dim belongs to a card, not to the viewport: only the bottom sheet's backdrop covers the page, and each card above the bottom is shaded by its own card-bounded scrim (0.6 of the dim colour directly under the top card, 0.8 deeper) — so the shade never deepens with the stack, at any depth. One backdrop blur total, and hidden sheets stay fully alive: state, scroll positions and React subtrees are untouched, and everything is restored when they come back up. Target the attributes to restyle the stacked states, or override the rules to opt out.closeDisabledand Escape. Escape closing a modal dialog is the platform contract;closeDisabledopts out by cancelling the dialog'scancelevent. Chromium honours that cancel only while the page has user activation, and an Escape keypress grants none, so a second Escape with no click or other key in between closes the dialog natively. The sheet detects the native close and tears down at once, firingonCloseandonExited, with no exit animation. X, backdrop and drag stay blocked for as long as the flag is set.Drag-to-dismiss is from the header / grabber. The content area uses
overscroll-behavior: contain, so scrolling a long body never dismisses the sheet. This is deliberate — a long read shouldn't end in an accidental close.A drag commits at half the card's own height, not half the viewport, so a short sheet closes at the same point in its own travel as a full-height one. A fast release commits far earlier: speed picks the destination, position only decides a slow one. The finger can also come back mid-return and catch the sheet, which cancels the snap-back and hands the gesture straight back.
iOS keyboard &
100dvh.dvhdoesn't shrink when the keyboard appears, so a pinnedfootercan sit behind it while a field is focused.focusOnOpenfixes the open seam; for a keyboard-following footer, drive it fromvisualViewport.Trigger-button
:hoveron iOS. iOS applies:hoveron tap and keeps it until another element is tapped — so a swipe-closed sheet leaves its trigger highlighted. Gate your own:hoverstyles behind@media (hover: hover); the built-in theme does.Password-manager autofill. An extension that shows its dropdown as a
popover="manual"element straight on<body>or<html>(iCloud Passwords in Chrome does) gets that element moved into the topmost sheet withmoveBefore(). It stays open, in place and focused. When that sheet closes, it moves to the sheet below, or back to where it was injected.- A password manager that puts its menu inside the dialog itself, as Bitwarden and Proton Pass do, is left alone.
- Nodes your page renders are left alone: only a node with inline
!importantstyles and no framework bookkeeping on it qualifies. A hand-built popover of exactly that shape, shown on<body>over a sheet, would be moved too — and put back when the sheet closes. - Once moved, the dropdown is part of the sheet, so Tab from the last field can reach it.
Your own popovers are DOM you control — see Popovers.
strategy: 'replace'. The replaced sheet closes silently —onExitedfires,onClosedoes not. A native close (a<form method="dialog">submit, or a browser force-close) tears down cleanly and fires both.The raw top layer is
pointer-events: none. Only reachable if you bypass the mount points and append toslots.toplayeryourself; children there must re-arm pointer events on the panel itself — a full-bleed wrapper that does it swallows backdrop-dismiss and drag-to-close.<SheetPortal layer="viewport">andlayers.viewporthandle it. (overlaySlotis unaffected: it isdisplay: contents, and its children are interactive as-is.)A slot that throws is contained to that slot. It renders nothing and logs to
console.error; the sheet and your app stay mounted. The slot stays blank until the sheet closes — there is no retry. Forward it with<SheetHost onSlotError>.Breakpoint crossing. The mobile/desktop decision is made once, at
open(). A sheet stays visible across a rotate or split-view change, but the drag gesture binds only on open — a sheet opened on desktop and then narrowed isn't draggable until it's reopened.Scrollbar compensation. While open, the sheet reserves the classic scrollbar's width as
bodypadding so the page doesn't shift. If you'd rather not reserve the gap, setscrollbar-gutter: stableonhtml— the gap becomes zero and nothing is added.