Getting started
Install morphcard from GitHub and wire a list to a detail sheet, in plain JavaScript or React.
Install
morphcard is not published to npm yet. Install it from GitHub:
pnpm add github:pieralukasz/morphcard
# or
npm install github:pieralukasz/morphcardThe package ships ESM and TypeScript types from dist/. It has no runtime dependencies. React is an optional peer dependency, used only by morphcard/react.
Markup
You need three elements next to each other. None may contain another.
<main class="list">
<article class="card" data-id="2042">
<button class="card-hit" aria-label="Open delivery 2042"></button>
<h3 data-morph="title">Lyon → Milan</h3>
<p data-morph="company">Bellweather Foods</p>
<p class="meta">2 of 3 stops done</p>
<span class="badge" data-morph="badge">In transit</span>
</article>
<!-- more cards -->
</main>
<div class="scrim" data-morph-close></div>
<section class="sheet" role="dialog" aria-modal="true" aria-label="Delivery">
<button data-morph-close data-morph-focus data-morph-stagger>Back</button>
<header>
<h1 data-morph="title"></h1>
<p data-morph="company"></p>
<span class="badge" data-morph="badge"></span>
</header>
<div data-morph-stagger>…stops…</div>
<div data-morph-stagger>…cargo…</div>
<footer data-morph-dock>…actions…</footer>
</section>The attributes:
| Attribute | Where | Effect |
|---|---|---|
data-morph="key" | card and sheet | The two elements with the same key fly between each other. |
data-morph-stagger | sheet | The block rises 10 px and fades in, one after another. |
data-morph-dock | sheet | A bar that slides up from the bottom edge. |
data-morph-close | sheet or scrim | A click closes the sheet. |
data-morph-focus | sheet, card | Receives focus after open (sheet) or after close (card). |
data-morph-mode="box" | card or sheet | Scale this pair by width, like an image. Default for img, svg, video, canvas. |
Anything in the sheet without a marker fades in with the content.
The arrows and other decorations inside a shared element move with it. Elements of the card without a marker (the meta line above) fade out as the sheet grows.
CSS
.sheet,
.scrim {
position: fixed;
inset: 0;
}
.sheet {
overflow: auto;
background: white;
}
.scrim {
background: rgb(15 23 42 / 0.4);
}
/* The library hides the sheet and scrim with the hidden attribute. */
[hidden] {
display: none !important;
}The sheet can also be position: absolute inside a positioned frame, as in the demo on the home page. It must be the full-size element: morphcard animates its clip-path from the card's rectangle to inset(0).
If a display rule in your CSS overrides [hidden], the sheet stays visible while closed. morphcard warns about this in the console when it starts.
Vanilla JavaScript
import { createMorph } from "morphcard";
const list = document.querySelector(".list");
const sheet = document.querySelector(".sheet");
const morph = createMorph({
sheet,
background: list,
scrim: document.querySelector(".scrim"),
// Runs before anything is measured. Fill the sheet for this card here.
prepare(card) {
const item = items.find((d) => d.id === card?.dataset.id);
sheet.querySelector("h1").textContent = item.title;
sheet.querySelector('[data-morph="company"]').textContent = item.company;
// ...
},
});
list.addEventListener("click", (event) => {
const card = event.target.closest(".card");
if (card) morph.open(card);
});prepare may return a promise, for example when the detail data is fetched. The transition starts when it resolves. Escape, the Back button and the scrim close the sheet without more code.
React
import { useState } from "react";
import { useMorph } from "morphcard/react";
export function Deliveries({ items }: { items: Delivery[] }) {
const morph = useMorph();
const [item, setItem] = useState<Delivery | null>(null);
return (
<>
<main ref={morph.backgroundRef}>
{items.map((d) => (
<article key={d.id} className="card">
<button
aria-label={`Open ${d.title}`}
onClick={(e) =>
morph.open(e.currentTarget.closest("article"), () => setItem(d))
}
/>
<h3 data-morph="title">{d.title}</h3>
<p data-morph="company">{d.company}</p>
</article>
))}
</main>
<div ref={morph.scrimRef} className="scrim" data-morph-close hidden />
<section ref={morph.sheetRef} className="sheet" role="dialog" hidden>
<button data-morph-close data-morph-focus>Back</button>
{item && (
<header>
<h1 data-morph="title">{item.title}</h1>
<p data-morph="company">{item.company}</p>
</header>
)}
</section>
</>
);
}The second argument of open is a state update. The hook runs it inside flushSync before measuring, so the sheet already shows the new item when the flight is planned. Keep the sheet mounted; the hook shows and hides it.
Check it works
Open the page, click a card and look at morph.plan in the console. It tells you what the last transition decided for each shared key:
morph.plan;
// { direction: "open", choreography: "morph", reduced: false, backgroundScaled: true,
// pairs: [{ key: "title", mode: "crossfade" }, { key: "company", mode: "scale" }, ...] }A pair with mode: "skip" has a reason, for example missing-sheet-element when the sheet has no element with that key.