List to detail page, a photo gallery and a wallet-style card, with the parts that differ from the basic setup.
Update the URL when the sheet opens and closes, and open without a flight when the page loads on a detail URL.
import { useEffect, useState } from "react";
import { useMorph } from "react-morphcard";
function Deliveries() {
const [id, setId] = useState<string | null>(null);
const morph = useMorph({
onStateChange(state, card) {
if (state === "opening" && card) history.pushState({ id: card.dataset.id }, "", `/deliveries/${card.dataset.id}`);
},
});
useEffect(() => {
// Deep link: no card, so the sheet fades in.
const fromUrl = idFromUrl();
if (fromUrl) morph.open(null, () => setId(fromUrl));
const onPop = () => {
if (!idFromUrl()) morph.close();
};
window.addEventListener("popstate", onPop);
return () => window.removeEventListener("popstate", onPop);
}, []);
// ...cards call morph.open(event.currentTarget, () => setId(delivery.id)).
// The Back button has no data-morph-close: it goes through history.
// <button onClick={() => history.back()}>Back</button>
}If the list re-renders while the sheet is open, the original card element is gone. Pass the new one when closing:
morph.close({ to: listRef.current?.querySelector(`[data-id="${id}"]`) ?? null });If it is not in the list any more, pass { to: null } and the sheet fades out.
Images fly by width. Mark the thumbnail and the large image with the same key:
<button className="tile" onClick={(e) => morph.open(e.currentTarget, () => setPhoto(p))}>
<img data-morph="photo" src={p.thumb} alt={p.alt} />
<span data-morph="caption">{p.caption}</span>
</button>
<section ref={morph.sheetRef} className="sheet" role="dialog" aria-label="Photo" hidden>
<img data-morph="photo" src={photo?.large} alt="" />
<h2 data-morph="caption">{photo?.caption}</h2>
<p data-morph-stagger>{photo?.story}</p>
<button data-morph-close data-morph-stagger>Close</button>
</section>When the thumbnail is cropped to a different aspect ratio than the large image (4:3 against 16:9), the photo does not stretch. Its frame changes shape while it flies, and the thumbnail stays in place inside the large photo, as a crop of it. Use object-fit: cover on both, and set the thumbnail's object-position when it is not cropped from the centre.
Set backgroundScale: false if the gallery is a grid of small tiles: scaling a dense grid reads as noise.
A payment card that lifts into a full screen with its details. The card and the sheet share a colour, so only the shape changes.
.pass {
background: #1e293b;
color: white;
border-radius: 20px;
}
.pass-sheet {
position: fixed;
inset: 0;
background: #1e293b;
color: white;
}const morph = useMorph({
duration: { open: 460, close: 320 },
backgroundScale: 0.94,
});The clip starts with the card's 20 px corners and ends at the sheet's own radius (square here). When the card and sheet backgrounds differ, the colour animates with the clip. Mark the card number and the logo with data-morph; the logo is an svg, so it scales by width.
const morph = useMorph({ timeScale: 4 });Everything, including the stagger, plays four times slower. Options are read on every render, so a toggle in state works too: useMorph({ timeScale: slow ? 4 : 1 }). The playground does exactly that for this site's own cards. The recordings on this site were made with the frame-by-frame recorder in scripts/record.mjs, which pauses every animation and steps it 1/60 s at a time.
Made by Lucas Piera