Notification Center
A bell-triggered notification popover with unread badge, all/unread tabs, and mark-all-read.
Installation
npx cano-ui add notification-center
Installs button, popover, tabs, lucide-react automatically if missing.
Source
"use client"
import * as React from "react"
import { Bell, Check } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
export interface NotificationItem {
id: string
title: string
description?: string
timestamp: string
read?: boolean
/** Lucide icon component (or any component accepting className). */
icon?: React.ComponentType<{ className?: string; "aria-hidden"?: boolean }>
}
export interface NotificationCenterProps {
notifications: NotificationItem[]
onNotificationClick?: (notification: NotificationItem) => void
onMarkAllRead?: () => void
emptyMessage?: string
className?: string
}
function NotificationRow({
notification,
onClick,
}: {
notification: NotificationItem
onClick?: () => void
}) {
const Icon = notification.icon
return (
<button
type="button"
onClick={onClick}
className="flex w-full gap-3 px-4 py-3 text-left transition-colors hover:bg-muted/50 focus-visible:bg-muted/50 focus-visible:outline-none"
>
<span className="relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted">
{Icon ? (
<Icon className="size-4 text-muted-foreground" aria-hidden={true} />
) : (
<Bell className="size-4 text-muted-foreground" aria-hidden={true} />
)}
{!notification.read ? (
<span
aria-hidden="true"
className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-primary ring-2 ring-popover"
/>
) : null}
</span>
<span className="flex min-w-0 flex-col gap-0.5">
<span
className={cn(
"truncate text-sm",
notification.read
? "text-muted-foreground"
: "font-medium text-foreground"
)}
>
{notification.title}
</span>
{notification.description ? (
<span className="line-clamp-2 text-xs text-muted-foreground">
{notification.description}
</span>
) : null}
<time className="text-xs text-muted-foreground/70">
{notification.timestamp}
</time>
</span>
{!notification.read ? (
<span className="sr-only">(unread)</span>
) : null}
</button>
)
}
function NotificationList({
notifications,
onNotificationClick,
emptyMessage,
}: {
notifications: NotificationItem[]
onNotificationClick?: (notification: NotificationItem) => void
emptyMessage: string
}) {
if (notifications.length === 0) {
return (
<p className="px-4 py-10 text-center text-sm text-muted-foreground">
{emptyMessage}
</p>
)
}
return (
<div className="flex max-h-80 flex-col divide-y overflow-y-auto">
{notifications.map((notification) => (
<NotificationRow
key={notification.id}
notification={notification}
onClick={
onNotificationClick
? () => onNotificationClick(notification)
: undefined
}
/>
))}
</div>
)
}
export function NotificationCenter({
notifications,
onNotificationClick,
onMarkAllRead,
emptyMessage = "You're all caught up.",
className,
}: NotificationCenterProps) {
const unread = notifications.filter((n) => !n.read)
return (
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
size="icon"
aria-label={
unread.length > 0
? `Notifications (${unread.length} unread)`
: "Notifications"
}
className={cn("relative", className)}
>
<Bell aria-hidden="true" />
{unread.length > 0 ? (
<span className="absolute -top-1 -right-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-medium tabular-nums text-primary-foreground">
{unread.length > 9 ? "9+" : unread.length}
</span>
) : null}
</Button>
</PopoverTrigger>
<PopoverContent
align="end"
className="w-80 p-0"
data-slot="notification-center"
>
<Tabs defaultValue="all">
<div className="flex items-center justify-between border-b px-4 py-2">
<TabsList className="h-8">
<TabsTrigger value="all" className="text-xs">
All
</TabsTrigger>
<TabsTrigger value="unread" className="text-xs">
Unread
{unread.length > 0 ? (
<span className="ml-1 tabular-nums text-muted-foreground">
{unread.length}
</span>
) : null}
</TabsTrigger>
</TabsList>
{onMarkAllRead ? (
<Button
variant="ghost"
size="sm"
className="h-7 gap-1 px-2 text-xs text-muted-foreground"
onClick={onMarkAllRead}
disabled={unread.length === 0}
>
<Check className="size-3.5" aria-hidden="true" />
Mark all read
</Button>
) : null}
</div>
<TabsContent value="all">
<NotificationList
notifications={notifications}
onNotificationClick={onNotificationClick}
emptyMessage={emptyMessage}
/>
</TabsContent>
<TabsContent value="unread">
<NotificationList
notifications={unread}
onNotificationClick={onNotificationClick}
emptyMessage={emptyMessage}
/>
</TabsContent>
</Tabs>
</PopoverContent>
</Popover>
)
}
This exact file lands in your project at components/ui/notification-center.tsx. It is yours to edit.