A grouped notification inbox with read/unread state, per-row mark-read, a bulk mark-all-read action and an animated unread count.
npx shadcn@latest add @paragon/notification-inbox"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
AtSign,
MessageSquare,
GitPullRequest,
UserPlus,
CheckCheck,
Check,
} from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/registry/paragon/ui/tooltip";
import { cn } from "@/lib/utils";
export type NotificationKind = "mention" | "comment" | "review" | "assign";
export interface InboxNotification {
id: string;
kind: NotificationKind;
actor: string;
/** The sentence body; actor is rendered separately in bold. */
text: string;
/** Group bucket, e.g. "Today", "Earlier". */
group: string;
time: string;
read?: boolean;
}
export interface NotificationInboxProps extends React.ComponentProps<"div"> {
notifications?: InboxNotification[];
groupOrder?: string[];
}
const kindMeta: Record<
NotificationKind,
{ icon: React.ElementType; className: string }
> = {
mention: { icon: AtSign, className: "text-primary" },
comment: { icon: MessageSquare, className: "text-muted-foreground" },
review: { icon: GitPullRequest, className: "text-success" },
assign: { icon: UserPlus, className: "text-warning" },
};
const DEFAULTS: InboxNotification[] = [
{ id: "1", kind: "mention", actor: "Marcus Kane", text: "mentioned you in ENG-482", group: "Today", time: "9m" },
{ id: "2", kind: "review", actor: "Dana Ortiz", text: "requested your review on “Retry queue dashboard”", group: "Today", time: "41m" },
{ id: "3", kind: "comment", actor: "Jamie Lin", text: "commented on the Q3 launch doc", group: "Today", time: "1h", read: true },
{ id: "4", kind: "assign", actor: "Priya Raghavan", text: "assigned ENG-490 to you", group: "Earlier", time: "Yesterday" },
{ id: "5", kind: "mention", actor: "Sofia Marin", text: "mentioned you in #design-review", group: "Earlier", time: "Yesterday", read: true },
{ id: "6", kind: "review", actor: "Noah Bennett", text: "approved your pull request", group: "Earlier", time: "2d", read: true },
];
const DEFAULT_ORDER = ["Today", "Earlier"];
/**
* A grouped notification inbox with read/unread state and bulk actions.
* Unread rows carry a leading dot and a faint tint; clicking a row (or its
* "mark read" affordance) clears it, and a header button marks everything
* read at once. An unread count badge animates on change. Rows exit with the
* house language when dismissed; reduced motion keeps just the fade.
*/
export function NotificationInbox({
notifications = DEFAULTS,
groupOrder = DEFAULT_ORDER,
className,
...props
}: NotificationInboxProps) {
const reduced = useReducedMotion();
const [items, setItems] = React.useState(notifications);
const unread = items.filter((n) => !n.read).length;
const markRead = (id: string) =>
setItems((list) =>
list.map((n) => (n.id === id ? { ...n, read: true } : n)),
);
const markAllRead = () =>
setItems((list) => list.map((n) => ({ ...n, read: true })));
const groups = React.useMemo(() => {
const map = new Map<string, InboxNotification[]>();
for (const item of items) {
const list = map.get(item.group) ?? [];
list.push(item);
map.set(item.group, list);
}
const ordered = [
...groupOrder.filter((g) => map.has(g)),
...[...map.keys()].filter((g) => !groupOrder.includes(g)),
];
return ordered.map((key) => [key, map.get(key)!] as const);
}, [items, groupOrder]);
return (
<TooltipProvider>
<div
data-slot="notification-inbox"
className={cn(
"flex w-full max-w-md flex-col overflow-hidden rounded-xl bg-card text-card-foreground shadow-border",
className,
)}
{...props}
>
<div className="flex items-center justify-between border-b border-border px-4 py-2.5">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">Inbox</span>
<AnimatePresence mode="popLayout" initial={false}>
{unread > 0 && (
<motion.span
key={unread}
initial={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.6 }}
animate={reduced ? { opacity: 1 } : { opacity: 1, scale: 1 }}
exit={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.6 }}
transition={{ type: "spring", duration: 0.3, bounce: 0.1 }}
className="rounded-full bg-primary px-1.5 py-0.5 text-[11px] font-medium text-primary-foreground tabular-nums"
>
{unread}
</motion.span>
)}
</AnimatePresence>
</div>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={markAllRead}
disabled={unread === 0}
className={cn(
"pressable inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium text-muted-foreground outline-none",
"transition-[scale,background-color,color] duration-(--duration-fast) hover:bg-accent hover:text-foreground",
"disabled:pointer-events-none disabled:opacity-40",
"focus-visible:ring-2 focus-visible:ring-ring",
)}
>
<CheckCheck className="size-3.5" aria-hidden />
Mark all read
</button>
</TooltipTrigger>
<TooltipContent>
{unread > 0
? `Clear ${unread} unread`
: "Nothing to clear"}
</TooltipContent>
</Tooltip>
</div>
<div className="max-h-96 overflow-y-auto [scrollbar-width:thin]">
{groups.map(([group, list]) => (
<div key={group}>
<p className="bg-secondary/40 px-4 py-1.5 text-[11px] font-medium tracking-wide text-muted-foreground uppercase">
{group}
</p>
<div className="divide-y divide-border">
<AnimatePresence initial={false}>
{list.map((item) => {
const meta = kindMeta[item.kind];
const Icon = meta.icon;
return (
<motion.button
key={item.id}
type="button"
layout={!reduced}
onClick={() => markRead(item.id)}
initial={false}
exit={
reduced
? { opacity: 0 }
: { opacity: 0, y: -12, filter: "blur(4px)" }
}
transition={{ duration: 0.15, ease: [0.4, 0, 1, 1] }}
className={cn(
"group/row relative flex w-full items-start gap-3 px-4 py-3 text-left outline-none",
"transition-colors duration-(--duration-fast) hover:bg-accent/40",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
!item.read && "bg-primary/[0.04] dark:bg-primary/[0.07]",
)}
>
<span
aria-hidden
className={cn(
"mt-1.5 flex size-2 shrink-0 items-center justify-center",
)}
>
{!item.read && (
<span className="size-2 rounded-full bg-primary" />
)}
</span>
<span
className={cn(
"mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full bg-secondary",
meta.className,
)}
>
<Icon className="size-3.5" aria-hidden />
</span>
<span className="min-w-0 flex-1">
<span className="text-sm leading-snug">
<span className="font-medium">{item.actor}</span>{" "}
<span className="text-foreground/80">{item.text}</span>
</span>
<span className="mt-0.5 block text-[11px] text-muted-foreground tabular-nums">
{item.time}
</span>
</span>
{!item.read && (
<span
aria-hidden
className={cn(
"mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-md text-muted-foreground opacity-0",
"transition-[opacity,color] duration-(--duration-fast) group-hover/row:opacity-100 group-hover/row:text-foreground",
"motion-reduce:transition-none",
)}
>
<Check className="size-3.5" />
</span>
)}
</motion.button>
);
})}
</AnimatePresence>
</div>
</div>
))}
{items.length === 0 && (
<p className="px-4 py-10 text-center text-sm text-muted-foreground">
You're all caught up.
</p>
)}
</div>
</div>
</TooltipProvider>
);
}