Shared-element morph
A card expands into a panel by morphing rather than cross-fading, because both states share one identity.
Grade: BRuntimes: motionStatus: live
Loading demo…
Intent
Cross-fading between a thumbnail and its detail view loses the thread — the viewer has to work out that the two are the same thing. Morphing keeps continuity, so the new view is understood as the old one opened rather than as a replacement.
Implementation
The `strict` prop on LazyMotion makes using the heavyweight `motion` component a runtime error, so the bundle saving cannot be lost by accident.
Dependencies: motion@^12, react@^19
"use client";
import { AnimatePresence, LazyMotion, domAnimation, m, useReducedMotion } from "motion/react";
import { useState } from "react";
export function SharedElement({ items }: { items: { id: string; title: string }[] }) {
const [openId, setOpenId] = useState<string | null>(null);
const reduce = useReducedMotion();
const open = items.find((item) => item.id === openId);
return (
<LazyMotion features={domAnimation} strict>
<ul className="grid grid-cols-3 gap-3">
{items.map((item) => (
<li key={item.id}>
<m.button
layoutId={reduce ? undefined : `card-${item.id}`}
onClick={() => setOpenId(item.id)}
>
{item.title}
</m.button>
</li>
))}
</ul>
<AnimatePresence>
{open ? (
<m.div key={open.id} layoutId={reduce ? undefined : `card-${open.id}`}>
<h3>{open.title}</h3>
<button onClick={() => setOpenId(null)}>Close</button>
</m.div>
) : null}
</AnimatePresence>
</LazyMotion>
);
}Use it when
- Grid item into detail view, thumbnail into hero, list row into panel.
- Anywhere the viewer should understand two views as one object in two states.
Avoid it when
- When the two states share nothing visually — the morph then looks like a bug.
- On elements that reflow while animating; projection assumes a stable box.
Accessibility
- Drop the `layoutId` under reduced motion — the states then swap instantly, which is correct rather than degraded.
- Move focus into the expanded panel and return it to the trigger on close; the morph is visual only.
- Keep the close control keyboard reachable — the morph gives no affordance by itself.
Performance
- `LazyMotion` with `domAnimation` and the `m` component, not `motion`: the full component is not tree-shakeable and costs roughly 34 kB against about 20 kB here.
- Layout projection and `position: sticky` do not cooperate. Pick one per element.
- Animate a handful of shared ids, not every child — each one is measured on every layout change.
Knobs
- layoutId
- The shared identity linking both states. Default: card-{id}
- features
- Which motion feature bundle to load. Default: domAnimation
Composition
Conflicts with
Instruction for Claude Code
Build a shared-element morph with motion: put the same layoutId on the collapsed card and the expanded panel, and wrap the expanded state in AnimatePresence so it animates out before unmounting. Nest layoutIds on the parts that should travel independently. Use LazyMotion with domAnimation and the m component rather than motion — the full component is not tree-shakeable — and pass strict so importing the heavy one becomes a runtime error. Set layoutId to undefined under useReducedMotion so the states swap instantly. Move focus into the panel on open and back to the trigger on close.