How to build an animated notification stack in React
An animated notification stack in React cycles through a data array on a setInterval, prepends each new item to a bounded visible list, and animates entries and exits with Framer Motion's AnimatePresence and layout prop, producing the staggered push-up effect where older notifications scroll off the top as new ones arrive at the bottom.
- Stack: React 18 + Framer Motion 11, zero icon library, ~150 lines.
- Core Framer Motion APIs: AnimatePresence (mode='popLayout'), motion.div layout, initial/animate/exit.
- Interval defaults to 2 000 ms; maxVisible defaults to 4, both configurable as props.
- Avatar is aria-hidden; the notification text is plain DOM, screen-reader friendly.
- The live timestamp ticks every second via a separate setInterval driving a forceUpdate.
This banner component renders a self-cycling stream of notification cards to simulate live platform activity, signups, purchases, reviews, whatever fits the product. Cards appear at the bottom, older ones push upward, and the stack never grows past four items. The effect reads as real-time social proof without any backend dependency.
Anatomy
The section has two parts. At the top, a centered header block holds a pill badge with a pulsing green dot labeled 'Live', a large headline, and a subtitle paragraph. Below it, a 480 px-wide flex column renders each notification as a card: a 36 px colored avatar circle on the left, a right column with the sender name, a relative timestamp flushed to the right, and the message text truncated to a single line with text-overflow:ellipsis.
How it works
Two `useEffect` hooks handle the lifecycle. The first runs once on mount to seed the visible array with the first two notifications, giving the stack an immediate populated appearance. The second sets a recurring interval at `intervalMs` (default 2 000 ms) that calls `addNotif`, which prepends the next notification in the rotation and slices the array to `maxVisible`. A third interval fires every second and calls `forceUpdate` to refresh the relative timestamps without touching the notification data. Framer Motion's `AnimatePresence mode='popLayout'` handles the visual transitions: new items enter from below (`y: 20, opacity: 0, scale: 0.97`), existing items shift upward via the `layout` prop, and departing items exit upward (`y: -16, opacity: 0, scale: 0.96`), all driven by a custom `[0.16, 1, 0.3, 1]` spring-like cubic-bezier.
How to build it in React
Define the data shape and seed the initial state
Each notification needs an `id`, `initials`, `name`, `message`, `color`, and a `timestamp` offset in seconds (used to make relative times feel spread out). On mount, pre-populate with the first two items so the stack is never empty on first render.
useEffect(() => { setVisible( notifications.slice(0, 2).map((n, i) => ({ ...n, id: Date.now() - i * 100, spawnedAt: Date.now() - (i + 1) * 6000, })) ); setTick(2); }, []);Cycle new notifications on an interval
Use a `setInterval` to prepend the next item from your array each tick. Slice the result to `maxVisible` so the list never grows unbounded. Track the current index with a `tick` counter incremented on each call.
const addNotif = useCallback(() => { setVisible((prev) => { const item = { ...notifications[tick % notifications.length], id: Date.now(), spawnedAt: Date.now(), }; return [item, ...prev].slice(0, maxVisible); }); setTick((t) => t + 1); }, [notifications, tick, maxVisible]); useEffect(() => { const t = setInterval(addNotif, intervalMs); return () => clearInterval(t); }, [addNotif, intervalMs]);Animate with AnimatePresence and layout
Wrap the list in `<AnimatePresence initial={false} mode='popLayout'>`. Give each `motion.div` a stable `key` (use `id`, not array index), the `layout` prop, plus `initial`, `animate`, and `exit` variants. The `layout` prop handles the push-up reflow automatically, no manual position math needed.
<AnimatePresence initial={false} mode="popLayout"> {visible.map((notif) => ( <motion.div key={notif.id} layout initial={{ opacity: 0, y: 20, scale: 0.97 }} animate={{ opacity: 1, y: 0, scale: 1 }} exit={{ opacity: 0, y: -16, scale: 0.96 }} transition={{ layout: { duration: 0.3 }, opacity: { duration: 0.25 } }} > {/* card content */} </motion.div> ))} </AnimatePresence>Keep timestamps alive with a second interval
The relative time display ('il y a 3s', 'il y a 2min') needs to update every second without triggering a full data refresh. Add a separate interval that calls a dummy state setter to force a re-render each second. The timestamp calculation then reads `Date.now()` fresh on every render.
const [, forceUpdate] = useState(0); useEffect(() => { const t = setInterval(() => forceUpdate((n) => n + 1), 1000); return () => clearInterval(t); }, []);
When to use it
Reach for this pattern on marketing landing pages where you need to convey platform activity and social proof without real-time data, SaaS signups, e-commerce purchases, course enrollments. It works best above a pricing section or before a primary CTA. Avoid it on dashboards or product UI where looping fake data would confuse users expecting real information. On mobile it renders fine, but keep messages short since the single-line truncation removes context on narrow screens.
Used by
- Fomo, A dedicated social proof SaaS product that displays real-time purchase and signup notifications as overlaid toast cards on customer websites.
- Proof, Conversion optimization tool whose core UI is a looping notification feed showing recent visitor signups and actions to new visitors.
- Gumroad, Creator marketplace that shows live purchase activity notifications on product pages to reinforce demand.
- TrustPulse, WordPress and WooCommerce plugin that injects animated activity notification stacks (purchases, signups, reviews) to boost conversions.
FAQ
Why use `mode='popLayout'` instead of the default AnimatePresence mode?
The default mode waits for an exiting element to finish before inserting the entering one, which creates a visible gap. `popLayout` lets Framer Motion remove the exiting element from the document flow immediately while still animating it as an overlay, so the remaining cards reposition without a jump.
Can I replace the fake data with real WebSocket events?
Yes. Replace the `setInterval` with a WebSocket or Server-Sent Events listener that calls `setVisible` the same way, prepend and slice. The AnimatePresence animation layer doesn't care where the data comes from.
How do I prevent layout shift when the component first renders?
Set `initial={false}` on `AnimatePresence`, this skips the mount animation for items already present on first render, so the pre-seeded notifications appear instantly without sliding in.
The animation stutters on low-end devices. How to fix it?
Add `will-change: transform, opacity` to the card style to hint the browser to promote it to a composited layer. Reducing `maxVisible` from 4 to 2 also cuts the number of simultaneously animated DOM nodes in half.