Animated List
Live notifications that slide in at the top of a stack.
components/ui/animated-list.tsx
"use client";
import { AnimatePresence, motion } from "motion/react";
import { CreditCard, MessageCircle, UserPlus, Zap, type LucideIcon } from "lucide-react";
import { useEffect, useState } from "react";
type Notice = { title: string; text: string; time: string; icon: LucideIcon; tone: string };
const notices: Notice[] = [
{ title: "Payment received", text: "$2,400 from Lumora Inc.", time: "now", icon: CreditCard, tone: "bg-emerald-100 text-emerald-600" },
{ title: "New signup", text: "Priya joined your workspace", time: "1m", icon: UserPlus, tone: "bg-blue-100 text-blue-600" },
{ title: "New message", text: "Can we move the call to 3pm?", time: "2m", icon: MessageCircle, tone: "bg-violet-100 text-violet-600" },
{ title: "Deploy finished", text: "Production is live in 41s", time: "4m", icon: Zap, tone: "bg-amber-100 text-amber-600" },
];
type AnimatedListProps = {
items?: Notice[];
/** Milliseconds between new items */
interval?: number;
/** Items visible at once */
max?: number;
};
export function AnimatedList({ items = notices, interval = 1800, max = 4 }: AnimatedListProps) {
const [list, setList] = useState<(Notice & { uid: number })[]>([]);
useEffect(() => {
let n = 0;
const add = () => {
const uid = n;
setList((prev) => [{ ...items[uid % items.length], uid }, ...prev].slice(0, max));
n++;
};
add();
const id = setInterval(add, interval);
return () => clearInterval(id);
}, [items, interval, max]);
return (
<ul className="flex w-full flex-col gap-2.5">
<AnimatePresence initial={false}>
{list.map((item) => (
<motion.li
key={item.uid}
layout
initial={{ opacity: 0, y: -24, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, scale: 0.95, transition: { duration: 0.2 } }}
transition={{ type: "spring", stiffness: 350, damping: 30 }}
className="flex items-center gap-3 rounded-2xl border border-zinc-200 bg-white p-3.5 shadow-sm"
>
<span className={`grid size-10 shrink-0 place-items-center rounded-xl ${item.tone}`}>
<item.icon className="size-5" />
</span>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-zinc-900">{item.title}</p>
<p className="truncate text-sm text-zinc-500">{item.text}</p>
</div>
<span className="text-xs text-zinc-400">{item.time}</span>
</motion.li>
))}
</AnimatePresence>
</ul>
);
}
export default function Demo() {
return (
<div className="h-[340px] w-full max-w-sm overflow-hidden">
<AnimatedList />
</div>
);
}
Installation
Install the dependencies
$ npm i motion lucide-reactCopy the source into
components/ui/animated-list.tsxcomponents/ui/animated-list.tsx"use client"; import { AnimatePresence, motion } from "motion/react"; import { CreditCard, MessageCircle, UserPlus, Zap, type LucideIcon } from "lucide-react"; import { useEffect, useState } from "react"; type Notice = { title: string; text: string; time: string; icon: LucideIcon; tone: string }; const notices: Notice[] = [ { title: "Payment received", text: "$2,400 from Lumora Inc.", time: "now", icon: CreditCard, tone: "bg-emerald-100 text-emerald-600" }, { title: "New signup", text: "Priya joined your workspace", time: "1m", icon: UserPlus, tone: "bg-blue-100 text-blue-600" }, { title: "New message", text: "Can we move the call to 3pm?", time: "2m", icon: MessageCircle, tone: "bg-violet-100 text-violet-600" }, { title: "Deploy finished", text: "Production is live in 41s", time: "4m", icon: Zap, tone: "bg-amber-100 text-amber-600" }, ]; type AnimatedListProps = { items?: Notice[]; /** Milliseconds between new items */ interval?: number; /** Items visible at once */ max?: number; }; export function AnimatedList({ items = notices, interval = 1800, max = 4 }: AnimatedListProps) { const [list, setList] = useState<(Notice & { uid: number })[]>([]); useEffect(() => { let n = 0; const add = () => { const uid = n; setList((prev) => [{ ...items[uid % items.length], uid }, ...prev].slice(0, max)); n++; }; add(); const id = setInterval(add, interval); return () => clearInterval(id); }, [items, interval, max]); return ( <ul className="flex w-full flex-col gap-2.5"> <AnimatePresence initial={false}> {list.map((item) => ( <motion.li key={item.uid} layout initial={{ opacity: 0, y: -24, scale: 0.95 }} animate={{ opacity: 1, y: 0, scale: 1 }} exit={{ opacity: 0, scale: 0.95, transition: { duration: 0.2 } }} transition={{ type: "spring", stiffness: 350, damping: 30 }} className="flex items-center gap-3 rounded-2xl border border-zinc-200 bg-white p-3.5 shadow-sm" > <span className={`grid size-10 shrink-0 place-items-center rounded-xl ${item.tone}`}> <item.icon className="size-5" /> </span> <div className="min-w-0 flex-1"> <p className="text-sm font-medium text-zinc-900">{item.title}</p> <p className="truncate text-sm text-zinc-500">{item.text}</p> </div> <span className="text-xs text-zinc-400">{item.time}</span> </motion.li> ))} </AnimatePresence> </ul> ); } export default function Demo() { return ( <div className="h-[340px] w-full max-w-sm overflow-hidden"> <AnimatedList /> </div> ); }Use it
The file exports the component and a
Demoshowing typical usage. Import whichever you need.