Complex examples
The edge cases: deep stacks, keyed re-opens, in-place rewrites and popovers inside the modal top layer. Every demo runs the real built package; each panel is one small source file, shown whole below its preview.
Stacked sheets
open() is a push — each call opens a new sheet over the current one, to any depth. The stacking is the native showModal() top layer, so it needs no z-index and no coordination: the top sheet holds the focus trap, Escape closes it alone, focus returns to the button that opened it, and the sheets underneath stay inert until they're on top again.
Rendering keeps a bounded deck: the covered card recedes behind the top sheet with a strip of it peeking above, the one below that parks fully occluded, and everything deeper keeps its state but stops painting its card — a frame costs the same at depth 12 as at depth 2. Dragging the top sheet reveals the cards beneath in real time. Each dim is bounded by the card it shades — one dim over the page, one over each visible card — so the shade stays constant however deep the stack goes, and never piles up toward black. The depth cap in this demo is a UX choice, not a performance one; see data-sheet-stack.
import {createSheets, SheetHost} from 'sheet-view/react'
const sheets = createSheets()
// A UX cap, not a technical one: only the top two sheets ever render.
const MAX_DEPTH = 12
// open() is a push: each call opens a new sheet over the current one.
function openStacked(depth: number) {
sheets.open({
title: `Stacked sheet — depth ${depth}`,
size: 'xl',
content: ({close}) => (
<div className="demo-body">
<p>
Depth {depth}. Escape closes this sheet only, and focus lands back on
the button that opened it.
</p>
<div className="demo-row">
{depth < MAX_DEPTH && (
<button className="demo-btn" onClick={() => openStacked(depth + 1)}>
Open depth {depth + 1}
</button>
)}
<button className="demo-btn" onClick={close}>
Close this one
</button>
<button className="demo-btn" onClick={() => sheets.closeAll()}>
Close all
</button>
</div>
</div>
),
})
}
export function Demo() {
return (
<>
<button className="demo-btn" onClick={() => openStacked(1)}>
Open depth 1
</button>
<SheetHost instance={sheets} />
</>
)
}Keyed + update
import {createSheets, SheetHost} from 'sheet-view/react'
const sheets = createSheets()
let count = 0
export function Demo() {
return (
<>
<button
className="demo-btn"
onClick={() => {
count += 1
sheets.open({
key: 'keyed',
strategy: 'update', // re-render the same keyed sheet in place, no stacking
title: `Keyed + update (#${count})`,
size: 'sm',
content: (
<div className="demo-body">
<p>Click again — same sheet, updated in place. Update count: {count}.</p>
</div>
),
})
}}
>
Open / update
</button>
<SheetHost instance={sheets} />
</>
)
}Rewrite an open sheet
The {update} ctx (or the handle's update()) merges new props into the open sheet in place: title, size, per-instance tokens, even the whole body — same dialog, no re-entrance animation.
import {createSheets, SheetHost} from 'sheet-view/react'
const sheets = createSheets()
export function Demo() {
return (
<>
<button
className="demo-btn"
onClick={() =>
sheets.open({
title: 'Original title',
size: 'md',
content: ({update}) => (
<div className="demo-body">
<p>
Each button rewrites the open sheet in place — same dialog, no
re-entrance animation. The leading icon and the close glyph are
moved into the rebuilt header, not recreated, so they survive
every rewrite.
</p>
<div className="demo-row">
<button
className="demo-btn"
onClick={() => update({title: 'Rewritten title'})}
>
Change title
</button>
<button className="demo-btn" onClick={() => update({size: 'xl'})}>
Grow the card
</button>
<button
className="demo-btn"
onClick={() => update({style: {'--sheet-width': '860px'}})}
>
Widen the desktop card
</button>
<button
className="demo-btn"
onClick={() =>
update({
content: (
<div className="demo-body">
{Array.from({length: 20}, (_, i) => (
<p key={i}>
Long-body line {i + 1} — the body swapped out from
under the open sheet.
</p>
))}
</div>
),
})
}
>
Swap the body
</button>
</div>
</div>
),
})
}
>
Open
</button>
<SheetHost instance={sheets} />
</>
)
}Anchored dropdown
A panel anchored to a trigger inside the scrolling body. <SheetPortal> mounts it in the card's anchor layer, so it escapes the body's clip, follows the card through the entrance and the drag, and selecting an option doesn't dismiss the sheet. Positioning is the app's — this one uses ~20 lines of getBoundingClientRect; use a positioning library in a real app. See Popovers.
import {useCallback, useEffect, useLayoutEffect, useRef, useState} from 'react'
import {createSheets, SheetHost, SheetPortal, useSheetLayout} from 'sheet-view/react'
const sheets = createSheets()
const OPTIONS = [
'Newest first',
'Oldest first',
'Price: low to high',
'Price: high to low',
'Best rated',
]
const ROW = 36
const PANEL_HEIGHT = OPTIONS.length * ROW + 8
// A dropdown with ZERO positioning dependencies, to show what the library
// guarantees and what it leaves to you. Swap this math for a positioning library
// in a real app: the mount point and the dismiss behaviour are what matter here.
//
// ARIA is deliberately minimal: this demo is about positioning and dismissal, not
// about the listbox pattern. Use your design system's combobox for that.
function SortSelect() {
const layout = useSheetLayout()
const triggerRef = useRef<HTMLButtonElement>(null)
const [open, setOpen] = useState(false)
const [value, setValue] = useState(OPTIONS[0])
const [pos, setPos] = useState({top: 0, left: 0, width: 0})
// Positioned against the CARD, not the viewport, because the panel is mounted
// inside the card: both rects carry the same transform, so their difference is
// right even mid-entrance. `scroll` is the clip boundary, so flip against it,
// not against the viewport, or the panel opens into a region the sheet cuts off.
const place = useCallback(() => {
const trigger = triggerRef.current
if (!trigger || !layout) return
const t = trigger.getBoundingClientRect()
const card = layout.card.getBoundingClientRect()
const clip = layout.scroll.getBoundingClientRect()
const flip = t.bottom + PANEL_HEIGHT > clip.bottom
setPos({
top: flip ? t.top - card.top - PANEL_HEIGHT - 4 : t.bottom - card.top + 4,
left: t.left - card.left,
width: t.width,
})
}, [layout])
useLayoutEffect(() => {
if (open) place()
}, [open, place])
// Only the body scroll needs a listener. The drag scroller does NOT: the panel
// lives inside the card, so a drag-to-close carries it along at finger speed
// with no repositioning at all.
useEffect(() => {
if (!open || !layout) return
const {content} = layout
content.addEventListener('scroll', place, {passive: true})
window.addEventListener('resize', place)
return () => {
content.removeEventListener('scroll', place)
window.removeEventListener('resize', place)
}
}, [open, layout, place])
return (
<>
<button
ref={triggerRef}
type="button"
className="demo-select"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
>
<span>{value}</span>
<span aria-hidden="true">▾</span>
</button>
{open && (
<SheetPortal>
<div
className="demo-listbox"
style={{
position: 'absolute',
top: pos.top,
left: pos.left,
minWidth: pos.width,
}}
>
{OPTIONS.map((opt) => (
<button
key={opt}
type="button"
className="demo-option"
data-selected={opt === value || undefined}
onClick={() => {
// This unmounts the panel mid-click, the case worth demoing.
// The press is inside the card, so it never dismisses the
// sheet, and the dismiss decision is taken on pointerdown, so
// the target being detached by click time changes nothing.
setValue(opt)
setOpen(false)
}}
>
{opt}
</button>
))}
</div>
</SheetPortal>
)}
</>
)
}
export function Demo() {
return (
<>
<button
className="demo-btn"
onClick={() =>
sheets.open({
title: 'Sort & filter',
size: 'md',
content: (
<div className="demo-body">
{Array.from({length: 8}, (_, i) => (
<p key={i}>
Paragraph {i + 1}. Scroll down — the trigger sits at the bottom of
this body, so its panel has to escape the content clip and flip
upward.
</p>
))}
<label className="demo-label">
Sort by
<SortSelect />
</label>
<p>
Pick an option: the sheet stays open. Scroll this body with the panel
open: it follows. On a narrow window, drag the sheet down: it rides
along.
</p>
</div>
),
})
}
>
Open a sheet with a dropdown
</button>
<SheetHost instance={sheets} />
</>
)
}Toast above the sheet
<SheetPortal layer="viewport"> puts a toast in the sheet's top layer, above the modal — and keeps backdrop-dismiss and drag-to-close working underneath it.
import {useEffect, useState} from 'react'
import {createSheets, SheetHost, SheetPortal} from 'sheet-view/react'
const sheets = createSheets()
const toast = (text: string): void =>
void window.dispatchEvent(new CustomEvent('demo-toast', {detail: text}))
// <SheetPortal layer="viewport"> puts the toast in the sheet's top layer, above the
// modal. No `?? document.body` fallback and no `pointer-events: auto` re-arm: the
// portal resolves a target either way, and its wrapper is display:contents, so it
// re-arms clicks for the toast without covering the viewport: backdrop-dismiss and
// drag-to-close still work with a toast on screen.
function Toasts() {
const [msg, setMsg] = useState<string | null>(null)
useEffect(() => {
const show = (e: Event): void => {
setMsg((e as CustomEvent<string>).detail)
setTimeout(() => setMsg(null), 2200)
}
window.addEventListener('demo-toast', show)
return () => window.removeEventListener('demo-toast', show)
}, [])
if (!msg) return null
// `instance` matters here and only here: this component sits at the app root, not
// inside a slot, so there is no sheet in context to read it from.
return (
<SheetPortal layer="viewport" instance={sheets}>
<div className="demo-toast">{msg}</div>
</SheetPortal>
)
}
export function Demo() {
return (
<>
<button
className="demo-btn"
onClick={() =>
sheets.open({
title: 'Toast above sheet',
size: 'md',
content: (
<div className="demo-body">
<button
className="demo-btn"
onClick={() => toast('Rendered above the sheet ✨')}
>
Show toast
</button>
<p>
With the toast up, press the dim or drag the sheet down — both still
dismiss it.
</p>
</div>
),
})
}
>
Open
</button>
<Toasts />
<SheetHost instance={sheets} />
</>
)
}