Skip to content

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.

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.

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>
</>
);
}

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.