Live demos
Every demo runs the real built package. Each panel is one small source file, shown whole below its preview.
Anatomy
One sheet with every structural part colour-coded, so you can see the pieces before wiring one up.
Vanilla
The framework-agnostic core on plain DOM — no React, no bundler.
Basic
import {createSheetCore} from 'sheet-view'
const sheets = createSheetCore()
function openBasic() {
sheets.open({
title: 'Basic sheet',
content: () => {
const body = document.createElement('div')
body.className = 'demo-body'
body.textContent = 'The core takes plain DOM nodes — no framework, no JSX.'
return body
},
})
}
export function mount(root) {
const button = document.createElement('button')
button.className = 'demo-btn'
button.textContent = 'Open a sheet'
button.addEventListener('click', openBasic)
root.append(button)
return () => sheets.closeAll()
}Themed
Per-instance theming: style sets --sheet-* tokens on the root, so they reach every part — including the backdrop, which a card class can't reach.
import {createSheetCore} from 'sheet-view'
const sheets = createSheetCore()
// A crisp vector × for the close button — inherits currentColor, centered by the
// button's flexbox. Replaces the default glyph.
function closeIcon() {
const NS = 'http://www.w3.org/2000/svg'
const svg = document.createElementNS(NS, 'svg')
svg.setAttribute('viewBox', '0 0 16 16')
svg.setAttribute('width', '15')
svg.setAttribute('height', '15')
svg.setAttribute('aria-hidden', 'true')
const path = document.createElementNS(NS, 'path')
path.setAttribute('d', 'M4 4 L12 12 M12 4 L4 12')
path.setAttribute('stroke', 'currentColor')
path.setAttribute('stroke-width', '1.6')
path.setAttribute('stroke-linecap', 'round')
svg.append(path)
return svg
}
function openThemed() {
sheets.open({
title: 'Themed',
// `style` sets --sheet-* tokens on the root dialog, so they reach every part —
// including the backdrop, which a card class can't touch.
style: {
'--sheet-surface': '#14121c',
'--sheet-text': '#f4f0ff',
'--sheet-handle': 'rgba(255, 255, 255, 0.3)',
'--sheet-backdrop': 'rgb(20 6 40 / 0.6)',
},
closeIcon: closeIcon(),
content: () => {
const body = document.createElement('div')
body.className = 'demo-body'
body.textContent =
'This one sheet is re-themed through the style prop — while the rest of the page stays light.'
return body
},
})
}
export function mount(root) {
const button = document.createElement('button')
button.className = 'demo-btn'
button.textContent = 'Open a themed sheet'
button.addEventListener('click', openThemed)
root.append(button)
return () => sheets.closeAll()
}React
The sheet-view/react adapter — createSheets() plus <SheetHost/>, slots widened to ReactNode.
Basic
import {createSheets, SheetHost} from 'sheet-view/react'
const sheets = createSheets()
export function Demo() {
return (
<>
<button
className="demo-btn"
onClick={() =>
sheets.open({
title: 'Basic sheet',
size: 'md',
content: (
<div className="demo-body">
{Array.from({length: 10}, (_, i) => (
<p key={i}>
Paragraph {i + 1}. A React body portaled into the core's DOM — the
content area scrolls on its own.
</p>
))}
</div>
),
})
}
>
Open a sheet
</button>
<SheetHost instance={sheets} />
</>
)
}Footer
A pinned footer over a scrolling body, plus both default-header glyphs supplied as plain JSX — icon before the title and closeIcon inside the close button. The button itself stays library-owned, so its label, its disabled state and its 44×44 hit target come for free.
import type {ReactElement} from 'react'
import {createSheets, SheetHost} from 'sheet-view/react'
const sheets = createSheets()
// Both header glyphs as plain JSX — the shape every icon library ships. They fill
// library-owned nodes, so the close button keeps its label, its disabled state and
// its 44×44 hit target. Nothing here replaces the header row.
const Sparkle = (): ReactElement => (
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true">
<path d="M8 1.5 9.6 6.4 14.5 8 9.6 9.6 8 14.5 6.4 9.6 1.5 8 6.4 6.4Z" fill="currentColor" />
</svg>
)
const Cross = (): ReactElement => (
<svg viewBox="0 0 16 16" width="15" height="15" aria-hidden="true">
<path
d="M4 4 L12 12 M12 4 L4 12"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
/>
</svg>
)
export function Demo() {
return (
<>
<button
className="demo-btn"
onClick={() =>
sheets.open({
title: 'With a footer',
size: 'md',
icon: <Sparkle />,
closeIcon: <Cross />,
content: (
<div className="demo-body">
<p>The footer stays pinned while the body scrolls.</p>
</div>
),
footer: ({close}) => (
<div className="demo-actions">
<button className="demo-btn" onClick={close}>
Apply & close
</button>
</div>
),
})
}
>
Open
</button>
<SheetHost instance={sheets} />
</>
)
}Form & autofill
A real <form> with autocomplete attributes, so password managers can offer autofill inside the modal sheet. focusOnOpen keeps the entrance keyboard-safe on mobile while the email field autofocuses. Built-in browser autofill works; extension-injected dropdowns can't be clicked over a modal dialog — see Notes & limitations.
import type {FormEvent} from 'react'
import {createSheets, SheetHost} from 'sheet-view/react'
const sheets = createSheets()
// A real <form> with autocomplete attributes, so password managers offer autofill.
function SignInForm({close}: {close: () => void}) {
const submit = (e: FormEvent): void => {
e.preventDefault()
close()
}
return (
<form className="demo-form" onSubmit={submit}>
<label>
Email
<input name="email" type="email" autoComplete="username" autoFocus required />
</label>
<label>
Password
<input name="password" type="password" autoComplete="current-password" required />
</label>
<button className="demo-btn" type="submit">
Sign in
</button>
</form>
)
}
export function Demo() {
return (
<>
<button
className="demo-btn"
onClick={() =>
sheets.open({
title: 'Sign in',
size: 'md',
focusOnOpen: true,
content: ({close}) => <SignInForm close={close} />,
})
}
>
Open a sign-in sheet
</button>
<SheetHost instance={sheets} />
</>
)
}Forced
A closeDisabled sheet: the X, backdrop, Escape, and drag are all blocked.
import {createSheets, SheetHost} from 'sheet-view/react'
const sheets = createSheets()
export function Demo() {
return (
<>
<button
className="demo-btn"
onClick={() =>
sheets.open({
title: 'Forced sheet',
size: 'sm',
closeDisabled: true, // blocks the X, backdrop, Escape, and drag
content: ({close}) => (
<div className="demo-body">
<p>Backdrop, Escape, and the X are blocked. Only this button closes it.</p>
<button className="demo-btn" onClick={close}>
Unlock & close
</button>
</div>
),
})
}
>
Open
</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} />
</>
)
}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.
import {createSheets, SheetHost} from 'sheet-view/react'
const sheets = createSheets()
const MAX_DEPTH = 5
// `open()` is a push: each call opens a NEW sheet over the current one, and
// nested showModal() dialogs stack natively — the top sheet holds the focus
// trap and Escape, everything underneath is inert until it's on top again.
function openStacked(depth: number) {
sheets.open({
title: `Stacked sheet — depth ${depth}`,
size: depth % 2 === 0 ? 'md' : '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} />
</>
)
}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 — 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. That is the whole reason to mount it in the card.
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} />
</>
)
}Native controls
<select>, date inputs and datalists need nothing from the library — their popups are OS-rendered, so the modal top layer doesn't apply to them.
import {createSheets, SheetHost} from 'sheet-view/react'
const sheets = createSheets()
// Native popups are rendered by the OS, not by the page, so they are unaffected by
// the modal top layer and need nothing from the library. The one case a page cannot
// fix is UI it does not own — see the "third-party overlays" limitation in the docs.
export function Demo() {
return (
<>
<button
className="demo-btn"
onClick={() =>
sheets.open({
title: 'Native controls',
size: 'md',
content: (
<div className="demo-body">
<p>
A <code><select></code>, a date input and a datalist combobox.
Every popup here is native UI — it opens over the sheet with no
portalling, no layer and no positioning code.
</p>
<label className="demo-label">
Country
<select className="demo-input" defaultValue="th">
<option value="th">Thailand</option>
<option value="vn">Vietnam</option>
<option value="id">Indonesia</option>
<option value="ph">Philippines</option>
</select>
</label>
<label className="demo-label">
Departure
<input className="demo-input" type="date" />
</label>
<label className="demo-label">
Airline
<input className="demo-input" list="demo-airlines" />
<datalist id="demo-airlines">
<option value="Thai Airways" />
<option value="Bangkok Airways" />
<option value="Vietnam Airlines" />
</datalist>
</label>
</div>
),
})
}
>
Open a sheet with native inputs
</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} />
</>
)
}