Animated List
A real-time push feed where new items animate in from the top with spring physics — Stripe Radar style.
Installation
File Structure
animated-list.tsx
Usage
import { AnimatedList } from "@/components/unlumen-ui/animated-list";
type Item = { id: number; message: string };
const [items, setItems] = useState<Item[]>([]);
// Prepend new items to show at top:
setItems((prev) => [newItem, ...prev].slice(0, 8));
<AnimatedList
items={items}
renderItem={(item) => (
<div className="rounded-xl border p-3">{item.message}</div>
)}
/>;API Reference
AnimatedList
itemsT[]—Array of items. Each item must have a unique `id` (string or number). Prepend new items at index 0 for top-push behavior.
renderItem(item: T, index: number) => React.ReactNode—Render function called for each visible item.
maxVisible?number8Maximum number of items rendered at once. Older items are removed.
gap?string"gap-3"Tailwind gap class applied between items.
className?string—Extra classes on the list container.
Notes
- Items must have a stable unique
idproperty (string or number) — this is required forAnimatePresenceto correctly track enter/exit animations. - Uses
AnimatePresence mode="popLayout"so exiting items don't block layout shifts. - New items enter with
y: -20 → 0andscale: 0.95 → 1on a spring; exits use a quick 150ms ease-in. - Cap your list with
maxVisibleto avoid accumulating too many DOM nodes in a live feed.
Keep in mind
Most components on this site are inspired by or recreated from existing work across the web. I'm not here to take credit; just to learn, experiment, and sometimes push things a bit further. If something looks familiar and I forgot to mention you, reach out and I'll fix that right away.
animated-list.tsx
"use client";
import * as React from "react";
import { AnimatePresence, motion } from "motion/react";
import { cn } from "@/lib/utils";
export type AnimationType = "scale" | "slide" | "fade" | "bounce";
export interface AnimatedListProps<T> {
/** index 0 = newest */
items: T[];
renderItem: (item: T, index: number) => React.ReactNode;
/** @default 8 */
maxVisible?: number;
/** @default 12 */
gap?: number;
/** @default "scale" */
animation?: AnimationType;
className?: string;
}
function getAnimationVariants(type: AnimationType) {
switch (type) {
case "slide":
return {
initial: { opacity: 0, y: -30 },
animate: { opacity: 1, y: 0 },
exit: { opacity: 0, y: -20 },
};
case "fade":
return {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0 },
};
case "bounce":
return {
initial: { opacity: 0, y: -20, scale: 0.8 },
animate: { opacity: 1, y: 0, scale: 1 },
exit: { opacity: 0, scale: 0.8 },
};
case "scale":
default:
return {
initial: { opacity: 0, y: -20, scale: 0.95 },
animate: { opacity: 1, y: 0, scale: 1 },
exit: { opacity: 0, scale: 0.9 },
};
}
}
export function AnimatedList<T extends { id: string | number }>({
items,
renderItem,
maxVisible = 8,
gap = 12,
animation = "scale",
className,
}: AnimatedListProps<T>) {
const visible = items.slice(0, maxVisible);
const variants = getAnimationVariants(animation);
return (
<div className={cn("flex flex-col", className)} style={{ gap: `${gap}px` }}>
<AnimatePresence mode="popLayout" initial={false}>
{visible.map((item, index) => (
<motion.div
key={item.id}
layout
initial={variants.initial}
animate={variants.animate}
exit={variants.exit}
transition={{
type: "spring",
stiffness: 350,
damping: 28,
layout: { type: "spring", stiffness: 350, damping: 28 },
}}
>
{renderItem(item, index)}
</motion.div>
))}
</AnimatePresence>
</div>
);
}