Morph a trigger into a sheet
trigger.style.viewTransitionName = 'morph';document.startViewTransition(() => { flushSync(() => setOpen(true)); trigger.style.viewTransitionName = '';});/* on the panel */view-transition-name: morph;--scrollsheet-travel: none;The trigger and the panel share one view-transition-name. document.startViewTransition
screenshots whichever element currently owns that name, runs your state change, then screenshots
whichever element owns it next, and cross-fades the box between the two positions and sizes. The
sheet’s own travel would play a slide or a zoom+fade underneath that at the same time, so
--scrollsheet-travel: none on the panel switches it off; the morph is the one entrance, and the
same class handles the exit in reverse.
Only one element can hold a given view-transition-name at a time or the transition throws. The
panel’s name can sit in CSS permanently: a closed <dialog> is display: none by the UA
stylesheet, and display: none elements don’t participate, so there’s never a moment the panel
and the trigger both carry it. The trigger’s copy is different: it stays on screen whether the
sheet is open or not, so its name has to be set right before the transition and cleared right
after, or the still-visible trigger and the now-visible panel would collide the instant the sheet
opens.
A button that grows into a modal
Section titled “A button that grows into a modal”The classic case: a button becomes a side="center" dialog in its place, not a modal that fades
in from nowhere.
import * as React from 'react';import { flushSync } from 'react-dom';import { Sheet } from 'scrollsheet';
type VTDocument = Document & { startViewTransition?: (update: () => void) => { finished: Promise<unknown> };};
function reducedMotion() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches;}
// A trigger that's scrolled off-screen or already unmounted has nowhere for// the reverse morph to land, so the guard checks it's still in the viewport,// not just still in the DOM.function onscreen(el: HTMLElement) { const r = el.getBoundingClientRect(); return r.bottom > 0 && r.right > 0 && r.top < window.innerHeight && r.left < window.innerWidth;}
/** Wires one trigger element to morph into its sheet by a shared `name`. */function useTriggerMorph(name: string, setOpen: (open: boolean) => void) { const triggerRef = React.useRef<HTMLButtonElement>(null);
const openMorph = () => { const doc = document as VTDocument; const trigger = triggerRef.current; if (!doc.startViewTransition || reducedMotion() || !trigger) { setOpen(true); return; } trigger.style.viewTransitionName = name; doc.startViewTransition(() => { flushSync(() => setOpen(true)); trigger.style.viewTransitionName = ''; }); };
const closeMorph = () => { const doc = document as VTDocument; const trigger = triggerRef.current; if (!doc.startViewTransition || reducedMotion() || !trigger || !onscreen(trigger)) { setOpen(false); return; } const transition = doc.startViewTransition(() => { flushSync(() => setOpen(false)); trigger.style.viewTransitionName = name; }); transition.finished.finally(() => { trigger.style.viewTransitionName = ''; }); };
return { triggerRef, openMorph, closeMorph };}
export function NewProjectButton() { const [open, setOpen] = React.useState(false); const { triggerRef, openMorph, closeMorph } = useTriggerMorph('new-project', setOpen);
return ( <> <button ref={triggerRef} type="button" onClick={openMorph}> New project </button> <Sheet.Root side="center" open={open} onOpenChange={(next) => !next && closeMorph()}> <Sheet.Content className="morph-panel" style={{ width: 360, viewTransitionName: 'new-project' }}> <Sheet.Title>New project</Sheet.Title> <Sheet.Description>Give it a name to get started.</Sheet.Description> <input autoFocus placeholder="Project name" /> <button type="button" onClick={closeMorph}>Create</button> </Sheet.Content> </Sheet.Root> </> );}.morph-panel { --scrollsheet-travel: none;}Every dismissal path has to route through closeMorph, including the ones that never touch the
Create button: backdrop click and Escape both call onOpenChange(false) on a controlled sheet
without touching open themselves, which is exactly the hook the morph needs to run before the
state actually flips.
A bottom sheet, same hook
Section titled “A bottom sheet, same hook”Nothing above is center-specific. --scrollsheet-travel: none degrades the slide-in the same
way it degrades the zoom+fade. A list row morphing into a bottom sheet reuses useTriggerMorph
as-is:
interface Order { id: string; title: string; detail: string;}
function OrderRow({ order }: { order: Order }) { const [open, setOpen] = React.useState(false); // Unique per row: many rows are mounted at once, and each needs its own name. const name = `order-${order.id}`; const { triggerRef, openMorph, closeMorph } = useTriggerMorph(name, setOpen);
return ( <> <button ref={triggerRef} type="button" className="order-row" onClick={openMorph}> {order.title} </button> <Sheet.Root open={open} onOpenChange={(next) => !next && closeMorph()} detents={['content', 'full']}> <Sheet.Content className="morph-panel" style={{ viewTransitionName: name }}> <Sheet.Handle /> <Sheet.Title>{order.title}</Sheet.Title> <p>{order.detail}</p> <Sheet.Close>Done</Sheet.Close> </Sheet.Content> </Sheet.Root> </> );}What this costs you
Section titled “What this costs you”Why not animate it in JS
Section titled “Why not animate it in JS”Nothing here is scrollsheet-specific plumbing. It’s the platform’s own API, wired to the two
state changes a sheet already exposes. The browser owns the crossfade and runs it as a compositor
animation, off the main thread, so it stays smooth under exactly the kind of load that would make
a JS-driven FLIP animation stutter. Feature-detecting it costs one if, and unsupported browsers
get the library’s regular entrance, not a broken one. That’s also why it isn’t a Sheet.Root prop:
the fallback is free, the morph is a browser feature the library doesn’t need to reimplement, and
wiring it costs nothing when it’s unavailable.
The Lightbox example is this same pattern doing more work: paging, pinch-zoom, and a chrome overlay riding its own timing on top of the morph. Read its source comments once this pattern feels familiar.