A pile of notification cards set back in real 3D space, so each card behind is smaller, higher and a little more faded into the page. The top card lifts from its bottom edge, swings off to one side and fades, alternating left and right. The rest spring one place forward and a fresh card slides in at the back, so the pile never empties. A thin bar on the top card counts down to the next peel and a counter rolls up as cards clear. On phones the copy sits above the pile.
import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion, useInView, useReducedMotion } from "framer-motion";
type IconName = "coin" | "chat" | "calendar" | "box" | "rocket" | "shield";
const notes: { app: string; icon: IconName; tone: string; time: string; title: string; body: string; action: string }[] = [
{ app: "Ledgerly", icon: "coin", tone: "bg-emerald-500", time: "now", title: "Invoice 2041 was paid", body: "Harbour Print Co. sent $1,280.00. It lands in your account on Thursday.", action: "View receipt" },
{ app: "Relay", icon: "chat", tone: "bg-sky-500", time: "2 min", title: "Priya mentioned you in #launch", body: "Can you sign off the pricing page before the 3 pm review?", action: "Reply" },
{ app: "Daybook", icon: "calendar", tone: "bg-rose-500", time: "9 min", title: "Design review moved to 3:30", body: "Tomas pushed it back half an hour. The room is still Juniper.", action: "Accept" },
{ app: "Parcelway", icon: "box", tone: "bg-amber-500", time: "24 min", title: "Your order is out for delivery", body: "Two packages, arriving between 1 pm and 3 pm. No signature needed.", action: "Track it" },
{ app: "Shipyard", icon: "rocket", tone: "bg-violet-500", time: "1 h", title: "Deploy 318 finished", body: "Production is on the new build. All 214 checks passed in 4 min 12 s.", action: "Open log" },
{ app: "Keyring", icon: "shield", tone: "bg-slate-700", time: "2 h", title: "New sign-in from Lisbon", body: "A laptop signed in to your account. If that was you, there is nothing to do.", action: "Review" },
];
/** Cards visible in the pile, pixels each one peeks above the next, and how far back each one sits. */
const DEPTH = 4;
const PEEK = 22;
const BACK = 85;
/** Milliseconds before the first card peels, and between cards after that. */
const FIRST = 1250;
const EVERY = 2100;
const CLEARED_BEFORE = 12;
const paths: Record<IconName, string> = {
coin: "M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16Zm1 3v1.2c1.4.3 2.3 1.2 2.4 2.5h-1.9c-.1-.6-.6-1-1.4-1-.9 0-1.4.4-1.4 1 0 1.7 4.9.6 4.9 3.7 0 1.4-1 2.3-2.6 2.6V18h-1.9v-1.1c-1.6-.3-2.6-1.3-2.7-2.8h1.9c.1.8.7 1.2 1.7 1.2s1.6-.4 1.6-1c0-1.8-4.9-.6-4.9-3.7 0-1.3.9-2.2 2.4-2.5V7H13Z",
chat: "M5 5h14a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-6l-4.5 3.5V17H5a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2Zm2.5 4.5v1.8h9V9.5h-9Zm0 3.2v1.8h6v-1.8h-6Z",
calendar: "M7 3h2v2h6V3h2v2h2a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h2V3Zm12 7H5v9h14v-9Zm-11 2.5h3v3H8v-3Z",
box: "M12 2.5 21 7v10l-9 4.5L3 17V7l9-4.5Zm0 2.2L6.5 7.5 12 10.2l5.5-2.7L12 4.7ZM5 9.1v6.7l6 3V12.1l-6-3Zm8 9.7 6-3V9.1l-6 3v6.7Z",
rocket: "M14.5 3c3 0 5.5.5 6.5 1 .5 1 1 3.5 1 6.5-1.5 3.5-4.5 6-8 7.5l-1 3-3-3-4-4-3-3 3-1c1.5-3.5 4-6.5 8.5-7Zm1.5 4.5a2 2 0 1 0 0 4 2 2 0 0 0 0-4ZM5 16l3 3-4.5 1.5L5 16Z",
shield: "M12 2.5 20 5.5v6c0 4.8-3.3 8.4-8 10-4.7-1.6-8-5.2-8-10v-6l8-3Zm3.6 6.4-4.6 4.7-2.1-2.1-1.4 1.4 3.5 3.5 6-6.1-1.4-1.4Z",
};
type Slot = { d: number; dir: number; first: boolean };
const variants = {
enter: (c: Slot) => (c.first ? { opacity: 0, x: 0, y: 110 - c.d * PEEK, z: -c.d * BACK, rotateX: -32, rotateY: 0, rotateZ: 0 } : { opacity: 0, x: 0, y: -(c.d + 1) * PEEK, z: -(c.d + 1) * BACK, rotateX: 0, rotateY: 0, rotateZ: 0 }),
rest: (c: Slot) => ({
opacity: 1,
x: 0,
y: -c.d * PEEK,
z: -c.d * BACK,
rotateX: 0,
rotateY: 0,
rotateZ: 0,
transition: { type: "spring" as const, stiffness: 230, damping: 24, delay: c.first ? 0.12 + (DEPTH - 1 - c.d) * 0.1 : c.d * 0.04 },
}),
// The peel: the bottom edge lifts toward you first, then the card swings off to one side.
exit: (c: Slot) => ({
opacity: [1, 1, 0],
x: [0, c.dir * 30, c.dir * 300],
y: [0, -26, -120],
z: [0, 130, 240],
rotateX: [0, 34, 58],
rotateY: [0, c.dir * -14, c.dir * -38],
rotateZ: [0, c.dir * 4, c.dir * 15],
zIndex: 50,
transition: { duration: 0.9, times: [0, 0.42, 1], ease: [0.32, 0, 0.2, 1] as [number, number, number, number], zIndex: { duration: 0 }, opacity: { duration: 0.9, times: [0, 0.78, 1], ease: "linear" as const } },
}),
gone: { opacity: 0, transition: { duration: 0 } },
};
export default function NotificationCardStackPeelingAwayIn3d() {
const reduce = useReducedMotion();
const still = Boolean(reduce);
const rootRef = useRef<HTMLElement>(null);
const inView = useInView(rootRef, { amount: 0.3 });
const [head, setHead] = useState(0);
const [auto, setAuto] = useState(true);
const running = auto && !still && inView;
useEffect(() => {
if (!running) return;
const id = window.setTimeout(() => setHead((h) => h + 1), head === 0 ? FIRST : EVERY);
return () => window.clearTimeout(id);
}, [running, head]);
// Deepest card first, so the top card is last in the page and paints over the rest.
const pile = Array.from({ length: DEPTH }, (_, i) => DEPTH - 1 - i).map((d) => ({ d, serial: head + d, note: notes[(head + d) % notes.length] }));
const cleared = CLEARED_BEFORE + head;
return (
<section ref={rootRef} className="relative overflow-hidden bg-sky-50 px-5 py-16 text-slate-900 sm:px-6 sm:py-24">
<span aria-hidden="true" className="pointer-events-none absolute -left-24 top-0 h-96 w-96 rounded-full bg-sky-200/70 blur-3xl" />
<span aria-hidden="true" className="pointer-events-none absolute -right-20 bottom-0 h-96 w-96 rounded-full bg-rose-200/60 blur-3xl" />
<div className="relative mx-auto grid max-w-5xl items-center gap-14 lg:grid-cols-2 lg:gap-10">
<motion.div initial={still ? false : { opacity: 0, y: 16 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.7, ease: [0.22, 1, 0.36, 1] }}>
<p className="text-sm font-semibold uppercase tracking-widest text-sky-700">Morning triage</p>
<h2 className="mt-4 text-4xl font-semibold tracking-tight sm:text-5xl">Clear the overnight pile one card at a time.</h2>
<p className="mt-5 max-w-md text-base leading-7 text-slate-600">Everything that arrived while you were away waits in one stack. Act on the top card or peel it off, and the next one is already there.</p>
<div className="mt-8 flex flex-wrap gap-3">
<a href="#" className="inline-flex h-12 items-center rounded-full bg-slate-900 px-6 text-sm font-semibold text-white transition hover:bg-slate-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-slate-900 focus-visible:ring-offset-2 focus-visible:ring-offset-sky-50">
Try it on your inbox
</a>
<button
type="button"
aria-pressed={!auto}
onClick={() => setAuto((a) => !a)}
className="inline-flex h-12 items-center gap-2 rounded-full border border-slate-300 bg-white/60 px-6 text-sm font-semibold text-slate-900 transition hover:border-slate-900 focus:outline-none focus-visible:ring-2 focus-visible:ring-slate-900 focus-visible:ring-offset-2 focus-visible:ring-offset-sky-50"
>
<svg viewBox="0 0 20 20" aria-hidden="true" className="h-4 w-4">
{auto ? <path fill="currentColor" d="M5 4h3.5v12H5V4Zm6.5 0H15v12h-3.5V4Z" /> : <path fill="currentColor" d="M6 3.5 16 10 6 16.5v-13Z" />}
</svg>
{auto ? "Pause the demo" : "Play the demo"}
</button>
</div>
</motion.div>
<div className="mx-auto w-full max-w-[420px]">
<div className="mb-5 flex items-center justify-between">
<p className="text-sm font-semibold">Notifications</p>
<p className="flex items-center gap-1.5 rounded-full bg-white px-3 py-1 text-xs font-medium text-slate-700 shadow-sm ring-1 ring-slate-900/5" aria-live="off">
<span className="relative inline-block h-4 overflow-hidden tabular-nums">
<AnimatePresence initial={false} mode="popLayout">
<motion.span key={cleared} className="block leading-4" initial={still ? false : { y: 14, opacity: 0 }} animate={{ y: 0, opacity: 1 }} exit={still ? { opacity: 0 } : { y: -14, opacity: 0 }} transition={{ type: "spring", stiffness: 420, damping: 30 }}>
{cleared}
</motion.span>
</AnimatePresence>
</span>
cleared today
</p>
</div>
<div className="relative h-[270px]" style={{ perspective: 1100, perspectiveOrigin: "50% 10%" }}>
<AnimatePresence initial={!still}>
{pile.map(({ d, serial, note }) => {
const top = d === 0;
const slot: Slot = { d, dir: serial % 2 ? 1 : -1, first: head === 0 };
return (
<motion.article
key={serial}
custom={slot}
variants={variants}
initial="enter"
animate="rest"
exit={still ? "gone" : "exit"}
aria-hidden={!top}
className="absolute inset-x-0 top-[66px] h-[204px] overflow-hidden rounded-3xl bg-white p-5 shadow-xl shadow-sky-900/10 ring-1 ring-slate-900/5"
style={{ transformOrigin: "50% 0%", willChange: "transform", zIndex: DEPTH - d }}
>
<div className="flex items-center gap-3">
<span className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-xl text-white ${note.tone}`}>
<svg viewBox="0 0 24 24" aria-hidden="true" className="h-5 w-5">
<path fill="currentColor" fillRule="evenodd" clipRule="evenodd" d={paths[note.icon]} />
</svg>
</span>
<p className="text-sm font-semibold">{note.app}</p>
<p className="ml-auto text-xs text-slate-500">{note.time}</p>
</div>
<h3 className="mt-4 truncate text-base font-semibold tracking-tight">{note.title}</h3>
<p className="mt-1 line-clamp-2 h-10 text-sm leading-5 text-slate-600">{note.body}</p>
<div className="mt-4 flex gap-2">
<button type="button" tabIndex={top ? 0 : -1} className="inline-flex h-10 items-center rounded-full bg-slate-900 px-4 text-sm font-semibold text-white transition hover:bg-slate-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-slate-900 focus-visible:ring-offset-2">
{note.action}
</button>
<button type="button" tabIndex={top ? 0 : -1} onClick={() => setHead((h) => h + 1)} className="inline-flex h-10 items-center rounded-full px-4 text-sm font-semibold text-slate-700 transition hover:bg-slate-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-slate-900 focus-visible:ring-offset-2">
Dismiss
</button>
</div>
{/* Cards further back sink into the page colour. */}
<motion.span aria-hidden="true" className="pointer-events-none absolute inset-0 bg-sky-100" initial={false} animate={{ opacity: d * 0.3 }} transition={{ duration: still ? 0 : 0.4 }} />
{top && running ? (
<motion.span key={`bar-${serial}`} aria-hidden="true" className="absolute inset-x-0 bottom-0 h-1 origin-left bg-sky-500" initial={{ scaleX: 0 }} animate={{ scaleX: 1 }} transition={{ duration: (head === 0 ? FIRST : EVERY) / 1000, ease: "linear" }} />
) : null}
</motion.article>
);
})}
</AnimatePresence>
</div>
<p className="mt-5 text-center text-sm text-slate-600">
{notes.length} apps, one pile. {auto && !still ? "The top card clears itself in a moment." : "Dismiss the top card to see the next."}
</p>
</div>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/notification-card-stack-peeling-away-in-3d.json
The cards are absolutely positioned on top of each other inside a wrapper with `perspective`. Each card gets a `z` offset (moving it away from the viewer) and a `y` offset based on its place in the pile. The browser's perspective does the shrinking, so the spacing looks right without hand-tuned scale values.
It is the exit animation of the top card. The card's transform origin is its top edge, so rotating on the X axis lifts the bottom edge toward the viewer first. The keyframes then add sideways travel, a twist and extra depth, and opacity only drops in the last part so the card stays solid while it lifts.
The component keeps one number, the index of the top card. The visible cards are the next few items from that index, wrapping around the list. Raising the index removes the top card, which triggers its exit, and adds a new one at the back, which fades in from one place deeper.
Yes. The Dismiss button on the top card advances the pile straight away, and the timer restarts from that point. To make the first button do real work, add an `onClick` to it and call your own handler with the note.
No. Only transform and opacity are animated, at most five cards exist at once and the timer stops when the section is not on screen.
Related: Animated Notification Feed with Looping Arrivals, Fanned Card Stack with Cycle Controls, Dark Toast Notification Stack with Undo and Progress.
More animated cards 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.