All componentsoverlay
Notification Center
Installation
$ npx shadcn@latest add https://deepmoon.dev/r/notification-center.jsonnpm dependencies: lucide-react
Props
NotificationCenterProps
| Prop | Type | Required |
|---|---|---|
| notifications | NotificationItem[] | Yes |
| onMarkRead | (id: string) => void | Yes |
| onMarkAllRead | () => void | No |
| className | string | No |
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'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>
);
}