Feedback & Data
NotificationStack
Notifications pile into a deck with the newest on top. Open it and the cards fan out into a list; dismiss one and the rest close the gap. Screen readers hear the new count.
Download
- React
- Tailwind CSS v4
- motion
components/ui/notification-stack.tsx
"use client";
import { useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type Notification = { id: string | number; title: string; description?: string; time?: string };
type NotificationStackProps = {
/** Newest first. */
items: Notification[];
/** Called with the id when a notification is dismissed. Remove it from `items` there. */
onDismiss: (id: Notification["id"]) => void;
/** Heading on the toggle. */
title?: string;
/** Height of one card in pixels. Long text is cut to fit. */
rowHeight?: number;
/** Tallest the open list gets before it scrolls. */
maxHeight?: number;
/** Replaces the card look. */
className?: string;
};
const GAP = 8;
const PEEK = 10;
const SHOWN = 3;
/* NotificationStack: notifications pile into a deck with the newest on top. Open it and the cards fan out into a list; dismiss one and the rest close the gap. */
export function NotificationStack({
items,
onDismiss,
title = "Notifications",
rowHeight = 68,
maxHeight = 320,
className = "rounded-2xl border border-white/10 bg-[#17151f] text-white shadow-lg",
}: NotificationStackProps) {
const reduce = useReducedMotion();
const toggle = useRef<HTMLButtonElement>(null);
const [wantOpen, setWantOpen] = useState(false);
const count = items.length;
const open = wantOpen && count > 1;
const spring = reduce ? { duration: 0 } : { type: "spring" as const, stiffness: 380, damping: 32 };
const pile = Math.min(count, SHOWN);
const height = open ? Math.min(count * (rowHeight + GAP) - GAP, maxHeight) : pile ? rowHeight + (pile - 1) * PEEK : 0;
const dismiss = (id: Notification["id"]) => {
onDismiss(id);
toggle.current?.focus();
};
return (
<section aria-label={title} className="w-full">
<button
ref={toggle}
type="button"
aria-expanded={open}
onClick={() => setWantOpen(!open)}
className="mb-2 flex w-full items-center gap-2 rounded-lg px-1 py-1.5 text-left text-sm font-medium text-white outline-none focus-visible:ring-2 focus-visible:ring-white/70"
>
{title}
<motion.span
key={count}
aria-hidden
className="grid h-5 min-w-5 place-items-center rounded-full bg-violet-400 px-1.5 text-xs font-semibold text-[#0b0a14] tabular-nums"
initial={reduce ? false : { scale: 1.4 }}
animate={{ scale: 1 }}
transition={spring}
>
{count}
</motion.span>
{count > 1 && (
<motion.svg aria-hidden viewBox="0 0 16 16" className="ml-auto size-4 text-white/60" animate={{ rotate: open ? 180 : 0 }} transition={spring}>
<path d="M3 6l5 5 5-5" fill="none" stroke="currentColor" strokeWidth={1.75} strokeLinecap="round" strokeLinejoin="round" />
</motion.svg>
)}
<span role="status" className="sr-only">{`${count} ${count === 1 ? "notification" : "notifications"}`}</span>
</button>
{count === 0 && <p className="px-1 py-3 text-sm text-white/50">All caught up.</p>}
<motion.ul className={`relative ${open ? "overflow-x-hidden overflow-y-auto" : ""}`} initial={false} animate={{ height }} transition={spring}>
<AnimatePresence initial={false}>
{items.map((item, i) => {
const depth = Math.min(i, SHOWN - 1);
return (
<motion.li
key={item.id}
inert={!open && i > 0}
style={{ height: rowHeight, zIndex: count - i }}
className="absolute inset-x-0 top-0 origin-bottom"
onClick={open ? undefined : () => setWantOpen(true)}
initial={{ y: -24, scale: 0.96, opacity: 0 }}
animate={open ? { y: i * (rowHeight + GAP), scale: 1, opacity: 1 } : { y: depth * PEEK, scale: 1 - depth * 0.05, opacity: i < SHOWN ? 1 - depth * 0.2 : 0 }}
exit={{ x: 48, opacity: 0 }}
transition={spring}
>
<div className={`h-full pr-2 pl-4 ${className}`}>
<div className={`flex h-full items-center gap-3 transition-opacity duration-200 ${open || i === 0 ? "" : "opacity-0"}`}>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{item.title}</p>
{item.description && <p className="truncate text-[13px] text-white/60">{item.description}</p>}
</div>
{item.time && <span className="shrink-0 text-xs text-white/40">{item.time}</span>}
<button
type="button"
aria-label={`Dismiss ${item.title}`}
onClick={(e) => {
e.stopPropagation();
dismiss(item.id);
}}
className="grid size-8 shrink-0 place-items-center rounded-lg text-white/50 outline-none hover:bg-white/10 hover:text-white focus-visible:ring-2 focus-visible:ring-white/70"
>
<svg aria-hidden viewBox="0 0 16 16" className="size-3.5">
<path d="M4 4l8 8M12 4l-8 8" fill="none" stroke="currentColor" strokeWidth={1.75} strokeLinecap="round" />
</svg>
</button>
</div>
</div>
</motion.li>
);
})}
</AnimatePresence>
</motion.ul>
</section>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/notification-stack.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/notification-stack-demo.tsx
"use client"; import { useRef, useState } from "react"; import { NotificationStack } from "@/components/ui/notification-stack"; const NEW = [ { title: "Payment received", description: "$49 from Northwind" }, { title: "New signup", description: "Priya joined the Pro plan" }, { title: "Build finished", description: "Production is up to date" }, ]; export default function NotificationStackExample() { const next = useRef(0); const [items, setItems] = useState([ { id: "a", title: "New comment", description: "Arun replied to your draft", time: "2m" }, { id: "b", title: "Invoice paid", description: "INV-1042 is settled", time: "1h" }, { id: "c", title: "Backup done", description: "Nightly backup took 4 minutes", time: "6h" }, { id: "d", title: "Weekly report", description: "Your numbers for last week are ready", time: "1d" }, ]); const add = () => { const n = next.current++; setItems((list) => [{ ...NEW[n % NEW.length], id: `new-${n}`, time: "now" }, ...list]); }; return ( <div className="grid h-full content-start justify-items-center gap-2 bg-[#0b0a14] p-3"> <div className="w-full max-w-sm"> <NotificationStack items={items} onDismiss={(id) => setItems((list) => list.filter((item) => item.id !== id))} rowHeight={60} maxHeight={144} /> </div> <button type="button" onClick={add} className="rounded-xl border border-white/10 bg-white/5 px-3 py-1.5 text-sm text-white/80 outline-none hover:bg-white/10 focus-visible:ring-2 focus-visible:ring-white/70" > Add notification </button> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| items | { id, title, description?, time? }[] | โ | Newest first. |
| onDismiss | (id) => void | โ | Called when one is dismissed. Remove it from items there. |
| title | string | "Notifications" | Heading on the toggle. |
| rowHeight | number | 68 | Height of one card in pixels. Long text is cut to fit. |
| maxHeight | number | 320 | Tallest the open list gets before it scrolls. |
| className | string | dark card | Replaces the card look. Keep it solid so cards behind stay hidden. |