Getting started

Install react-morphcard and wire a list to a detail sheet with the useMorph hook.

Install

bun add react-morphcard
# or
npm install react-morphcard

The package ships ESM and TypeScript types from dist/. It needs React 18 or 19 and react-dom, and has no other dependencies.

It works in any modern browser, in installed PWAs and in WebViews. It does not work in React Native, because it animates DOM elements with the Web Animations API.

The component

You need three elements next to each other: the list, a scrim, and the detail sheet. None may contain another. The hook gives you a ref for each one, plus a ref for every card.

import { useMorph } from "react-morphcard";

export function Deliveries({ items }: { items: Delivery[] }) {
  const morph = useMorph<Delivery>();
  const d = morph.item;

  return (
    <>
      <main ref={morph.backgroundRef} className="list">
        {items.map((item) => (
          <article key={item.id} ref={morph.cardRef(item.id)} className="card">
            <button
              className="card-hit"
              aria-label={`Open delivery ${item.id}`}
              onClick={() => morph.open({ key: item.id, item })}
            />
            <h3 data-morph="title">{item.title}</h3>
            <p data-morph="company">{item.company}</p>
            <p className="meta">{item.meta}</p>
            <span className="badge" data-morph="badge">{item.status}</span>
          </article>
        ))}
      </main>

      <div ref={morph.scrimRef} className="scrim" data-morph-close hidden />

      <section
        ref={morph.sheetRef}
        className="sheet"
        role="dialog"
        aria-modal="true"
        aria-label="Delivery"
        hidden
      >
        <button data-morph-close data-morph-focus data-morph-stagger>Back</button>
        {d && (
          <>
            <header>
              <h1 data-morph="title">{d.title}</h1>
              <p data-morph="company">{d.company}</p>
              <span className="badge" data-morph="badge">{d.status}</span>
            </header>
            <div data-morph-stagger>{d.stops}</div>
            <div data-morph-stagger>{d.cargo}</div>
            <footer data-morph-dock>…actions…</footer>
          </>
        )}
      </section>
    </>
  );
}

morph.open({ key, item }) finds the card registered under key, sets morph.item to item inside flushSync, and only then measures both ends. The sheet already shows the new item when the flight is planned. morph.item goes back to null after the sheet has closed, not before, so the header does not empty while it flies back.

Keep the sheet mounted. The hook hides it with the hidden attribute while it is closed. Escape, the Back button and the scrim close the sheet without more code.

With your own state

If the selected item already lives in your state or in a router, pass the card element and an update. The hook runs the update inside flushSync before measuring:

const morph = useMorph();
const [selected, setSelected] = useState<Delivery | null>(null);

<button onClick={(e) => morph.open(e.currentTarget.closest("article"), () => setSelected(item))} />

Next.js

The built file starts with "use client" and does not touch window or document when it is imported or rendered on the server. Use the hook in a client component as usual.

The attributes

AttributeWhereEffect
data-morph="key"card and sheetThe two elements with the same key fly between each other.
data-morph-staggersheetThe block rises 10 px and fades in, one after another.
data-morph-docksheetA bar that slides up from the bottom edge.
data-morph-closesheet or scrimA click closes the sheet.
data-morph-focussheet, cardReceives focus after open (sheet) or after close (card).
data-morph-mode="box"card or sheetScale 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 hook 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: the hook 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. The hook warns about this in the console when the sheet mounts.

Check it works

Open the page, click a card and read morph.instance.plan, for example from a console.log in onStateChange. It tells you what the last transition decided for each shared key:

morph.instance.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.

Made by Lucas Piera