SaaS & Ops

Notification Inbox

A grouped notification inbox with read/unread state, per-row mark-read, a bulk mark-all-read action and an animated unread count.

Install

npx shadcn@latest add @paragon/notification-inbox

notification-inbox.tsx

"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>
  );
}