morphcard

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/morphcard

The 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:

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

On this page