A banking-style card that holds a short list of payments. Choosing a row replaces the list with that payment's receipt, and the pieces the two views share (the coloured icon, the name, the category and the amount) travel from their place in the row to their place in the receipt while the card itself resizes on a spring. The extra receipt lines fade up a moment later. Closing sends everything back to the row. The card is 320 pixels wide at most and sits under the copy on phones.
// Adapted from Animata (https://animata.design/docs/list/transaction-list)
// Copyright (c) Animata. MIT License: https://we.inc/components/licenses
import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { ArrowRight, Bike, Coffee, CreditCard, Leaf, Music, X } from "lucide-react";
const transactions = [
{ id: "TX-48213", name: "Juniper Coffee", type: "Cafes", amount: 6.4, date: "Tuesday 14 October", time: "8:12 am", method: "Tap to pay", card: "4821", icon: Coffee, tone: "bg-amber-500" },
{ id: "TX-48207", name: "Fieldhouse Market", type: "Groceries", amount: 58.92, date: "Monday 13 October", time: "6:47 pm", method: "Chip and PIN", card: "4821", icon: Leaf, tone: "bg-emerald-600" },
{ id: "TX-48198", name: "Spoke and Chain", type: "Bike repair", amount: 34.0, date: "Sunday 12 October", time: "11:05 am", method: "Tap to pay", card: "7730", icon: Bike, tone: "bg-sky-600" },
{ id: "TX-48190", name: "Tideline Music", type: "Subscription", amount: 11.99, date: "Saturday 11 October", time: "12:00 am", method: "Card on file", card: "7730", icon: Music, tone: "bg-fuchsia-600" },
];
const spring = { type: "spring" as const, stiffness: 350, damping: 30 };
const money = (amount: number) => `$${amount.toFixed(2)}`;
export default function TransactionListThatMorphs() {
const reduce = useReducedMotion();
const [selectedId, setSelectedId] = useState<string | null>(null);
const touched = useRef(false);
const closeRef = useRef<HTMLButtonElement>(null);
const rowRefs = useRef<Record<string, HTMLButtonElement | null>>({});
const lastId = useRef<string | null>(null);
const selected = transactions.find((t) => t.id === selectedId) ?? null;
const move = reduce ? { duration: 0 } : spring;
// Open one receipt shortly after load so the preview shows the morph. Stops once the visitor takes over.
useEffect(() => {
if (reduce) return;
const timer = setTimeout(() => {
if (!touched.current) setSelectedId(transactions[1].id);
}, 1100);
return () => clearTimeout(timer);
}, [reduce]);
const open = (id: string) => {
touched.current = true;
setSelectedId(id);
};
const close = () => {
touched.current = true;
setSelectedId(null);
};
// Keyboard focus follows the visitor: to the close button on open, back to the row on close.
useEffect(() => {
if (!touched.current) return;
if (selectedId) closeRef.current?.focus();
else if (lastId.current) rowRefs.current[lastId.current]?.focus();
lastId.current = selectedId;
}, [selectedId]);
return (
<section className="bg-gradient-to-br from-violet-200 via-indigo-100 to-sky-200 px-4 py-16 sm:px-6">
<div className="mx-auto grid max-w-4xl items-center gap-10 md:grid-cols-2">
<div className="text-center md:text-left">
<p className="text-sm font-semibold uppercase tracking-widest text-indigo-800">Everyday account</p>
<h2 className="mt-3 text-3xl font-semibold tracking-tight text-slate-900 sm:text-4xl">Every payment has a story. Tap one to read it.</h2>
<p className="mt-4 text-base leading-relaxed text-slate-700">
The row you choose grows into its own receipt. The icon, name and amount travel to their new places, so you never lose track of what you opened.
</p>
</div>
<div className="mx-auto flex min-h-[420px] w-full max-w-xs items-start justify-center">
<motion.div layout transition={move} className="w-full overflow-hidden bg-white shadow-xl shadow-indigo-900/10" style={{ borderRadius: 28 }}>
<AnimatePresence mode="popLayout" initial={false}>
{!selected ? (
<motion.div key="list" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: reduce ? 0 : 0.15 }}>
<h3 className="px-6 pt-5 text-base font-semibold text-slate-600">Recent payments</h3>
<ul className="space-y-0.5 p-2">
{transactions.map((t) => (
<li key={t.id}>
<motion.button
ref={(node) => {
rowRefs.current[t.id] = node;
}}
type="button"
layoutId={`row-${t.id}`}
transition={move}
onClick={() => open(t.id)}
aria-label={`${t.name}, ${t.type}, ${money(t.amount)}. Open receipt`}
className="flex w-full items-center justify-between rounded-2xl p-2 text-left transition-colors hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-600"
>
<span className="flex items-center gap-3">
<motion.span layoutId={`icon-${t.id}`} transition={move} className={`flex h-10 w-10 items-center justify-center text-white ${t.tone}`} style={{ borderRadius: 20 }}>
<t.icon aria-hidden="true" className="h-5 w-5" />
</motion.span>
<span>
<motion.span layoutId={`name-${t.id}`} transition={move} className="block text-sm font-medium text-slate-900">
{t.name}
</motion.span>
<motion.span layoutId={`type-${t.id}`} transition={move} className="block text-xs text-slate-600">
{t.type}
</motion.span>
</span>
</span>
<motion.span layoutId={`amount-${t.id}`} transition={move} className="pr-1 text-sm font-semibold tabular-nums text-slate-700">
{money(t.amount)}
</motion.span>
</motion.button>
</li>
))}
</ul>
<div className="px-3 pb-3">
<a
href="#"
className="flex h-11 w-full items-center justify-center gap-2 rounded-xl bg-slate-100 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-600"
>
All payments
<ArrowRight aria-hidden="true" className="h-4 w-4" />
</a>
</div>
</motion.div>
) : (
<motion.div key="detail" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: reduce ? 0 : 0.15 }} className="p-5">
<div className="mb-4 flex items-center justify-between">
<motion.div layoutId={`row-${selected.id}`} transition={move}>
<motion.span layoutId={`icon-${selected.id}`} transition={move} className={`flex h-14 w-14 items-center justify-center text-white ${selected.tone}`} style={{ borderRadius: 18 }}>
<selected.icon aria-hidden="true" className="h-6 w-6" />
</motion.span>
</motion.div>
<motion.button
ref={closeRef}
type="button"
onClick={close}
aria-label="Close receipt and go back to the list"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: reduce ? 0 : 0.15 }}
className="flex h-10 w-10 items-center justify-center rounded-full bg-slate-100 text-slate-700 transition-colors hover:bg-slate-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-600"
>
<X aria-hidden="true" className="h-4 w-4" />
</motion.button>
</div>
<div className="flex items-start justify-between border-b border-dashed border-slate-300 pb-4">
<div>
<motion.h3 layoutId={`name-${selected.id}`} transition={move} className="text-lg font-semibold text-slate-900">
{selected.name}
</motion.h3>
<motion.p layoutId={`type-${selected.id}`} transition={move} className="text-sm text-slate-600">
{selected.type}
</motion.p>
</div>
<motion.p layoutId={`amount-${selected.id}`} transition={move} className="text-xl font-bold tabular-nums text-slate-900">
{money(selected.amount)}
</motion.p>
</div>
<motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: reduce ? 0 : 0.1, duration: reduce ? 0 : 0.25 }}>
<dl className="mt-4 space-y-2 text-sm">
{[
{ label: "Reference", value: selected.id },
{ label: "Date", value: selected.date },
{ label: "Time", value: selected.time },
].map((row) => (
<div key={row.label} className="flex justify-between gap-4">
<dt className="text-slate-600">{row.label}</dt>
<dd className="font-medium text-slate-900">{row.value}</dd>
</div>
))}
</dl>
<div className="mt-4 flex items-center gap-3 rounded-2xl bg-slate-100 p-3">
<CreditCard aria-hidden="true" className="h-5 w-5 text-slate-600" />
<p className="text-sm text-slate-700">
<span className="font-medium text-slate-900">{selected.method}</span> with card ending {selected.card}
</p>
</div>
<a
href="#"
className="mt-3 flex h-11 w-full items-center justify-center rounded-xl bg-indigo-600 text-sm font-semibold text-white transition-colors hover:bg-indigo-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-600 focus-visible:ring-offset-2"
>
Download receipt
</a>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
</div>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/transaction-list-that-morphs-into-a-receipt-detail.json
Both views render the same element with the same `layoutId`, for example `icon-TX-48207`. When one view leaves and the other enters, framer-motion measures the old and new boxes and animates between them, so it looks like one element moving.
`AnimatePresence` with `mode="popLayout"` takes the leaving view out of the layout at once. That lets the card measure its new height immediately and resize on the same spring as the shared elements.
During a layout animation the card is scaled, which would squash rounded corners. Framer-motion corrects the distortion only for radius values it can read from the style prop.
Yes. The outer card has the `layout` prop, so it grows or shrinks to whatever the current view needs. Keep a `min-h` on the wrapper, as here, so content below the card does not jump.
It suits quick look-ups inside a widget. For details people will share or bookmark, keep a real page and use this as a preview that links to it.
Adapted from Transaction List in Animata, used under its open-source licence. Licences and credits
Related: Card That Morphs into a Detail Dialog, Money Transfer Status Card with Merging Rows, Expandable Ticket Card with Spring Height and Blur Reveal.
More app and game ui or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.