Skip to content

API reference

Anatomy

Compose a sheet by passing slots to open() — each slot fills one part.

js
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 }
tsx
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:

html
<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>
  <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:

html
<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.

OptionTypeDefaultNotes
closeMsnumber320Exit budget for a button/backdrop/Escape/programmatic close.
dragCloseMsnumber220Exit budget for a drag-close (transform off a frozen scroller).
enterMsnumber400Mobile entrance duration — the JS mirror of --sheet-enter-duration.
openSettleMsnumberenterMsDelay after open before drag-to-close arms.
breakpointnumber768Viewport width (px) below which the mobile slide-up layout applies.
zoomLockbooleanfalsePins maximum-scale=1 while open to block iOS focus zoom. Off by default — a WCAG 1.4.4 trade-off.
closeLabelstring'Close'Default accessible label for close buttons in this instance.

closeMs / dragCloseMs should be the exit durations in CSS (--sheet-exit-duration, --sheet-backdrop-duration), or a close is cut short.

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.

PropTypeNotes
titlestringDefault-header title (ignored when headerSlot is set). Omit it and no default header is built at all — and so no close button.
iconSheetSlotLeading 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.
contentSheetSlotThe scrollable body.
footerSheetSlotPinned footer; collapses when empty.
headerSlotSheetSlotReplaces the default title/close row.
overlaySlotSheetSlotDecorations anchored to the card, free to extend past edges.
focusOnOpenbooleanAutofocus a field; keyboard-safe entrance on mobile.
ariaLabelstringAccessible name. Without it the title names the dialog — via aria-labelledby for the default header, or as aria-label when headerSlot owns the row.
closeLabelstringAccessible label for the close button. Default 'Close'.
closeIconSheetSlotGlyph inside the close button, in place of the default ×. The button stays library-owned — its name still comes from closeLabel.
closeDisabledbooleanBlocks X/backdrop/Escape/drag; fires onCloseAttempt.
closeHiddenbooleanOmits the default close button (a forced sheet).
cardClassNamestringClass(es) on the card element.
classNamestringClass(es) on the root dialog.
styleRecord<string,string>Inline styles/tokens on the root — reaches every part.
keystringDedupe scope for a singleton sheet.
strategy'reuse' | 'replace' | 'update'With key. Default 'reuse'.
onClose() => voidFired when a close is requested (not for a blocked/silent close).
onCloseAttempt() => voidFired when a close is blocked by closeDisabled.
onExited() => voidFired after the exit animation, before DOM removal.

SheetSlot = Node | string | null | undefined | ((ctx) => Node | string | null), where ctx is {close, update}.

SheetHandle

ts
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.

ts
import {SheetHost, SheetPortal, sheets, createSheets} from 'sheet-view/react'
ExportWhat it is
sheetsthe 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

MethodNotes
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

ts
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

  • title with a custom headerSlot. Passing headerSlot makes the adapter drop title from the rendered row — your header fully replaces the default title/close row, so the two can't collide. The name is not dropped, though: title becomes the dialog's aria-label, so the sheet still has an accessible name. Keep it matching the heading you render, or pass ariaLabel — a visible name that differs from the accessible one is a WCAG 2.5.3 (label in name) failure.
  • Wanting only a glyph? Use icon for a leading one and closeIcon for the close button — both take ReactNode, so a lucide-react / FontAwesome component goes straight in. Reach for headerSlot only to replace the row itself: it costs you the close button too, and its label, its aria-disabled behaviour and its 44×44 hit target all become yours to rebuild.

Testing

ts
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): DialogShim

jsdom 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:

js
// 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:

js
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() touches document; 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-lock on <html>. While any sheet is open the core sets this attribute (value = number of open sheets) and base.css applies the actual overflow: clip / body pin. Nothing is ever written to html.style.overflow or body.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; the data-sheet-scroll-gap / data-sheet-scroll-pin attributes and --_sheet-lock-* properties beside it are internal.

  • 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.

  • iOS keyboard & 100dvh. dvh doesn't shrink when the keyboard appears, so a pinned footer can sit behind it while a field is focused. focusOnOpen fixes the open seam; for a keyboard-following footer, drive it from visualViewport.

  • Trigger-button :hover on iOS. iOS applies :hover on tap and keeps it until another element is tapped — so a swipe-closed sheet leaves its trigger highlighted. Gate your own :hover styles behind @media (hover: hover); the built-in theme does.

  • Third-party overlays the page doesn't own (password-manager autofill). showModal() makes everything outside the dialog inert, and an extension injects its dropdown into the page, not into your dialog. It draws above the sheet but can't be clicked; the pointer falls through to whatever sits beneath it — and if that's the dim, the sheet closes. This is per-spec top-layer behaviour (whatwg/html#9936), and a page can't override it: you cannot move someone else's DOM inside your dialog. Bitwarden and 1Password detect modal dialogs and work around it; iCloud Passwords currently doesn't. Built-in browser autofill (mobile, Chrome's own manager, Safari) is native UI and unaffected.

    This is not a limit on your own popovers — the rule is only "is the DOM inside the <dialog>", and your dropdowns are DOM you control. See Popovers.

  • strategy: 'replace'. The replaced sheet closes silently — onExited fires, onClose does 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 to slots.toplayer yourself; 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"> and layers.viewport handle it. (overlaySlot is unaffected: it is display: 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 body padding so the page doesn't shift. If you'd rather not reserve the gap, set scrollbar-gutter: stable on html — the gap becomes zero and nothing is added.