A light, soft-coloured section with a headline on one side and a live notification feed on the other. Every second and a half a new card drops in at the top with a small blur and scale, the cards below slide down on a spring, and the oldest one fades away at the bottom. A counter in the header ticks up with each arrival. The feed pauses on hover, has its own Pause button and stops when it is off screen. On phones the feed sits under the copy at full width.
import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion, useInView, useReducedMotion } from "framer-motion";
const ARRIVAL_MS = 1500;
const VISIBLE = 5;
const feed = [
{ title: "Payment received", body: "$240.00 from Odette Marsh for 2 items", tone: "bg-emerald-500", icon: "M3.5 7.5h17v10h-17zM3.5 11h17M7 14.5h3" },
{ title: "New subscriber", body: "[email protected] joined the Friday letter", tone: "bg-indigo-500", icon: "M12 11.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7ZM5 19.5c1-3.4 3.8-5 7-5s6 1.6 7 5" },
{ title: "Low stock", body: "Linen apron, oat: 4 left at this pace until Sunday", tone: "bg-amber-500", icon: "M12 4.5 20.5 19h-17L12 4.5ZM12 10.5v4M12 16.8v.2" },
{ title: "Order shipped", body: "Order 5417 left the Leeds warehouse", tone: "bg-sky-500", icon: "M3 7.5h10v8H3zM13 10.5h4l3 3v2h-7M7 17.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3ZM17 17.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z" },
{ title: "New review", body: "Sunniva gave the walnut board five out of five", tone: "bg-violet-500", icon: "M5 6h14v9h-8.5L6.5 18.5V15H5V6ZM9 10.5h6" },
{ title: "Refund requested", body: "Piet Dekker asked to return order 5398", tone: "bg-rose-500", icon: "M5 12a7 7 0 1 0 2.3-5.2M5 4.5v3.5h3.5" },
{ title: "Pickup booked", body: "Courier collects 14 parcels tomorrow at 9:30", tone: "bg-teal-500", icon: "M4.5 6.5h15v13h-15zM4.5 10.5h15M8.5 4.5v4M15.5 4.5v4" },
];
type Row = (typeof feed)[number] & { id: number };
const starting: Row[] = [4, 3, 2, 1, 0].map((k, i) => ({ ...feed[k], id: -i - 1 }));
export default function AnimatedNotificationFeedWithLoopingArrivals() {
const [rows, setRows] = useState<Row[]>(starting);
const [count, setCount] = useState(12);
const [paused, setPaused] = useState(false);
const [hover, setHover] = useState(false);
const next = useRef(5);
const ref = useRef<HTMLElement>(null);
const inView = useInView(ref, { amount: 0.25 });
const still = !!useReducedMotion();
const running = inView && !still && !paused && !hover;
useEffect(() => {
if (!running) return;
const id = window.setInterval(() => {
const k = next.current;
next.current = k + 1;
setRows((r) => [{ ...feed[k % feed.length], id: k }, ...r].slice(0, VISIBLE));
setCount((c) => c + 1);
}, ARRIVAL_MS);
return () => window.clearInterval(id);
}, [running]);
return (
<section ref={ref} className="relative overflow-hidden px-5 py-16 text-slate-900 sm:px-6 sm:py-24" style={{ background: "linear-gradient(160deg, #eef0ff 0%, #f6f1ff 45%, #e6f3ff 100%)" }}>
<div aria-hidden="true" className="pointer-events-none absolute -left-24 top-10 h-80 w-80 rounded-full bg-indigo-300 opacity-30 blur-3xl" />
<div aria-hidden="true" className="pointer-events-none absolute -right-16 bottom-0 h-72 w-72 rounded-full bg-sky-300 opacity-30 blur-3xl" />
<div className="relative mx-auto grid max-w-5xl items-center gap-12 lg:grid-cols-2">
<div className="min-w-0">
<p className="text-sm font-semibold text-indigo-700">Pollen store alerts</p>
<h2 className="mt-3 text-4xl font-semibold tracking-tight sm:text-5xl">Know the second your shop does something.</h2>
<p className="mt-4 max-w-md text-lg text-slate-600">Sales, signups, stock and returns arrive in one tidy feed, on your phone and on the till.</p>
<div className="mt-8 flex flex-wrap items-center gap-3">
<a href="#" className="inline-flex min-h-[44px] items-center rounded-full bg-indigo-600 px-6 text-sm font-semibold text-white outline-none transition hover:bg-indigo-500 focus-visible:ring-2 focus-visible:ring-indigo-600 focus-visible:ring-offset-2">Turn on alerts</a>
<a href="#" className="inline-flex min-h-[44px] items-center rounded-full px-4 text-sm font-semibold text-slate-800 outline-none transition hover:text-indigo-700 focus-visible:ring-2 focus-visible:ring-indigo-600">Choose what you hear about</a>
</div>
</div>
<div className="mx-auto w-full min-w-0 max-w-md" onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}>
<div className="rounded-[2rem] border border-white bg-white/50 p-3 shadow-2xl shadow-indigo-200 backdrop-blur">
<div className="flex items-center justify-between px-3 pb-3 pt-2">
<div className="flex items-center gap-2.5">
<p className="text-sm font-semibold text-slate-900">Today</p>
<span className="inline-flex h-6 items-center overflow-hidden rounded-full bg-indigo-600 px-2 text-xs font-semibold tabular-nums text-white">
<AnimatePresence mode="popLayout" initial={false}>
<motion.span key={count} initial={still ? false : { y: 12, opacity: 0 }} animate={{ y: 0, opacity: 1 }} exit={{ y: -12, opacity: 0 }} transition={{ type: "spring", stiffness: 500, damping: 30 }}>
{count}
</motion.span>
</AnimatePresence>
<span className="ml-1">new</span>
</span>
</div>
<button
type="button"
aria-pressed={paused}
onClick={() => setPaused((p) => !p)}
className="inline-flex min-h-[40px] items-center gap-2 rounded-full px-3 text-xs font-semibold text-slate-700 outline-none transition hover:bg-white focus-visible:ring-2 focus-visible:ring-indigo-600"
>
<span className="relative flex h-2 w-2" aria-hidden="true">
{running && <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-500 opacity-70 motion-reduce:animate-none" />}
<span className={`relative inline-flex h-2 w-2 rounded-full ${paused ? "bg-slate-400" : "bg-emerald-500"}`} />
</span>
{paused ? "Resume feed" : "Pause feed"}
</button>
</div>
<div className="relative h-[404px] overflow-hidden rounded-3xl">
<ul className="relative space-y-2.5" aria-label="Recent store activity">
<AnimatePresence initial={false} mode="popLayout">
{rows.map((n, i) => (
<motion.li
key={n.id}
layout={still ? false : "position"}
initial={still ? false : { opacity: 0, y: -46, scale: 0.9, filter: "blur(6px)" }}
animate={{ opacity: i >= VISIBLE - 1 ? 0.45 : 1, y: 0, scale: 1, filter: "blur(0px)" }}
exit={still ? { opacity: 0 } : { opacity: 0, scale: 0.94, y: 24, filter: "blur(4px)", transition: { duration: 0.25 } }}
transition={{ type: "spring", stiffness: 360, damping: 30 }}
className="flex items-center gap-3.5 rounded-2xl bg-white p-3.5 shadow-sm shadow-indigo-100"
>
<span className={`flex h-11 w-11 flex-none items-center justify-center rounded-xl text-white ${n.tone}`} aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" className="h-5 w-5">
<path d={n.icon} />
</svg>
</span>
<span className="min-w-0 flex-1">
<span className="flex items-center justify-between gap-3">
<span className="truncate text-sm font-semibold text-slate-900">{n.title}</span>
<span className="flex flex-none items-center gap-1.5 text-xs text-slate-500">
{i === 0 && <span className="h-1.5 w-1.5 rounded-full bg-indigo-600" aria-hidden="true" />}
{i === 0 ? "now" : `${i * 2}m ago`}
</span>
</span>
<span className="mt-0.5 block truncate text-sm text-slate-600">{n.body}</span>
</span>
</motion.li>
))}
</AnimatePresence>
</ul>
<div aria-hidden="true" className="pointer-events-none absolute inset-x-0 bottom-0 h-14" style={{ background: "linear-gradient(to bottom, rgba(243,242,255,0), rgba(243,242,255,0.7))" }} />
</div>
</div>
</div>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/animated-notification-feed-with-looping-arrivals.json
Each card is a `motion.li` with the `layout` prop. When a new card is added at the top, framer-motion measures where every card was and where it is now, and animates the difference with a transform. No heights are hard-coded.
The list is wrapped in `AnimatePresence` with `mode="popLayout"`. When the array is trimmed, the removed card is taken out of the layout at once but stays on screen long enough to play its `exit` animation.
For real events, yes: add `role="log"` and `aria-live="polite"` to the list so new items are read without stealing focus. For a marketing demo that loops, leave it silent as it is here.
The animation relies on React knowing which card is which between renders. The demo gives each arrival an increasing `id`. With real data use the event's own id, never the array index.
Yes. Change the section gradient to a dark colour, the cards from `bg-white` to `bg-slate-800` with light text, and the bottom fade gradient to match the new panel colour.
Related: Dark Toast Notification Stack with Undo and Progress, Dismissible Alert Set: Success, Warning, Error and Info, Animated Bento Grid with Live Mini Demos.
More alerts and feedback 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.