deepmoon

Notification Center

Installation

$ npx shadcn@latest add https://deepmoon.dev/r/notification-center.json

npm dependencies: lucide-react

Props

NotificationCenterProps
PropTypeRequired
notificationsNotificationItem[]Yes
onMarkRead(id: string) => voidYes
onMarkAllRead() => voidNo
classNamestringNo

Code

notification-center.tsx
"use client";

import * as React from "react";
import { Bell, Check } from "lucide-react";

import { cn } from "@/lib/utils";

export interface NotificationItem {
  id: string;
  title: string;
  description?: string;
  timestamp?: string;
  read: boolean;
}

export interface NotificationCenterProps {
  notifications: NotificationItem[];
  onMarkRead: (id: string) => void;
  onMarkAllRead?: () => void;
  className?: string;
}

/**
 * Non-modal popover (closes on outside click / Escape, doesn't trap focus
 * like a dialog would — it's not blocking interaction with the rest of the
 * page). Unread state is shape-coded (a dot + bold weight), not color
 * alone. A newly-arrived notification is announced via aria-live,
 * detected by comparing the list length across renders.
 */
export function NotificationCenter({ notifications, onMarkRead, onMarkAllRead, className }: NotificationCenterProps) {
  const [open, setOpen] = React.useState(false);
  const [announcement, setAnnouncement] = React.useState("");
  const rootRef = React.useRef<HTMLDivElement>(null);
  const previousCount = React.useRef(notifications.length);
  const unreadCount = notifications.filter((n) => !n.read).length;

  React.useEffect(() => {
    if (notifications.length > previousCount.current) {
      const added = notifications.length - previousCount.current;
      setAnnouncement(`${added} new notification${added > 1 ? "s" : ""}.`);
    }
    previousCount.current = notifications.length;
  }, [notifications.length]);

  React.useEffect(() => {
    if (!open) return;
    function handlePointerDown(event: PointerEvent) {
      if (rootRef.current && !rootRef.current.contains(event.target as Node)) setOpen(false);
    }
    function handleKeyDown(event: KeyboardEvent) {
      if (event.key === "Escape") setOpen(false);
    }
    document.addEventListener("pointerdown", handlePointerDown);
    document.addEventListener("keydown", handleKeyDown);
    return () => {
      document.removeEventListener("pointerdown", handlePointerDown);
      document.removeEventListener("keydown", handleKeyDown);
    };
  }, [open]);

  return (
    <div ref={rootRef} className={cn("relative inline-block", className)}>
      <button
        type="button"
        aria-expanded={open}
        aria-haspopup="true"
        aria-label={`Notifications${unreadCount > 0 ? ` (${unreadCount} unread)` : ""}`}
        onClick={() => setOpen((o) => !o)}
        className="relative flex size-9 items-center justify-center rounded-md text-foreground outline-none transition-colors hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring"
      >
        <Bell className="size-5" aria-hidden="true" />
        {unreadCount > 0 ? (
          <span
            aria-hidden="true"
            className="absolute right-1.5 top-1.5 flex size-2 rounded-full bg-destructive"
          />
        ) : null}
      </button>

      <span aria-live="polite" className="sr-only">
        {announcement}
      </span>

      {open ? (
        <div className="absolute right-0 z-20 mt-2 w-80 rounded-lg border border-border bg-popover text-popover-foreground shadow-lg">
          <div className="flex items-center justify-between gap-2 border-b border-border px-3 py-2">
            <span className="text-sm font-semibold">Notifications</span>
            {onMarkAllRead && unreadCount > 0 ? (
              <button
                type="button"
                onClick={onMarkAllRead}
                className="text-xs font-medium text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
              >
                Mark all read
              </button>
            ) : null}
          </div>
          <ul role="list" className="max-h-80 list-none overflow-y-auto">
            {notifications.length === 0 ? (
              <li className="px-3 py-6 text-center text-sm text-muted-foreground">You&apos;re all caught up.</li>
            ) : (
              notifications.map((notification) => (
                <li key={notification.id} className="border-b border-border last:border-b-0">
                  <button
                    type="button"
                    onClick={() => !notification.read && onMarkRead(notification.id)}
                    className="flex w-full items-start gap-2 px-3 py-2.5 text-left outline-none transition-colors hover:bg-accent focus-visible:bg-accent"
                  >
                    <span
                      aria-hidden="true"
                      className={cn(
                        "mt-1.5 size-1.5 shrink-0 rounded-full",
                        notification.read ? "bg-transparent" : "bg-primary",
                      )}
                    />
                    <span className="flex flex-1 flex-col gap-0.5">
                      <span
                        className={cn(
                          "text-sm text-foreground",
                          !notification.read && "font-semibold",
                        )}
                      >
                        {notification.title}
                      </span>
                      {notification.description ? (
                        <span className="text-xs text-muted-foreground">{notification.description}</span>
                      ) : null}
                      {notification.timestamp ? (
                        <span className="text-xs text-muted-foreground">{notification.timestamp}</span>
                      ) : null}
                    </span>
                    {notification.read ? <Check className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" aria-hidden="true" /> : null}
                  </button>
                </li>
              ))
            )}
          </ul>
        </div>
      ) : null}
    </div>
  );
}