Notification center
The bell that carries the unread count, and the list of what happened.
The trigger says the count out loud — "Notifications, 2 unread" — because the bell is a picture and the pill on it is a bare numeral. Rows are real buttons, or links when they carry an href — pass renderLink to route those through your router rather than a plain anchor — so the keyboard reaches every one of them and the Mark all as read control sits in the header beside the title. Clicking a row marks it read and opens it; a row that is already read is not re-marked, so no no-op update reaches your state. unreadCount(notifications) is exported and pure — count with it in the caller rather than keeping a second tally. The list scrolls at 24rem inside the upstream ScrollArea, and the type colours only the row icon, using the success, warning, danger, and info tokens. NotificationMark is exported for lists that show the same avatar-or-tone circle elsewhere. The bell carries data-slot="notification-center" and the panel inside the popup data-slot="notification-center-content".
Install
npx shadcn@latest add @vibra/notification-centerNeeds the @vibra registry in your components.json — set it up once.
Examples
"use client"
import * as React from "react"
import {
NotificationCenter,
unreadCount,
type Notification,
} from "@/components/ui/notification-center"
// A fixed reference instant, not Date.now(): a statically built page freezes
// the server's copy at build time, so a live clock here would read one thing in
// the HTML and another once the reader's own clock takes over. format-demo.tsx
// pins its instant for the same reason.
const NOW = new Date(2026, 8, 4, 12, 0, 0).getTime()
const INITIAL: Notification[] = [
{
id: "n_8241",
title: "Deploy finished",
description: "web-app 4.12.0 is live in production.",
time: new Date(NOW - 3 * 60_000),
read: false,
type: "success",
},
{
id: "n_8240",
title: "Usage at 92% of plan",
description: "1.84M of 2M events this cycle.",
time: new Date(NOW - 48 * 60_000),
read: false,
type: "warning",
},
{
id: "n_8239",
title: "Priya Raman commented on Weekly revenue",
description: "“Can we split this by region before Thursday?”",
time: new Date(NOW - 2 * 60 * 60_000),
read: true,
avatar: { name: "Priya Raman" },
},
{
id: "n_8238",
title: "Webhook delivery failed",
description: "orders.created returned 502 four times.",
time: new Date(NOW - 5 * 60 * 60_000),
read: true,
type: "danger",
},
{
id: "n_8237",
title: "Invoice INV-2093 paid",
description: "$1,240.00 from Blue Harbor Logistics.",
time: new Date(NOW - 22 * 60 * 60_000),
read: true,
type: "info",
},
{
id: "n_8236",
title: "Marcus Bell accepted your invitation",
time: new Date(NOW - 2 * 24 * 60 * 60_000),
read: true,
avatar: { name: "Marcus Bell" },
},
{
id: "n_8235",
title: "Scheduled export completed",
description: "customers-weekly.csv · 42,918 rows.",
time: new Date(NOW - 3 * 24 * 60 * 60_000),
read: true,
type: "success",
},
{
id: "n_8234",
title: "API key rotated",
description: "sk_live_…f21c was replaced by Ada Lovelace.",
time: new Date(NOW - 6 * 24 * 60 * 60_000),
read: true,
},
]
export default function NotificationCenterDemo() {
const [notifications, setNotifications] = React.useState(INITIAL)
const markRead = (id: string) =>
setNotifications((current) =>
current.map((notification) =>
notification.id === id ? { ...notification, read: true } : notification
)
)
return (
<div className="flex w-full max-w-sm flex-col gap-3">
<div className="flex h-12 items-center justify-between gap-3 rounded-lg border px-3">
<span className="text-sm font-medium">Northwind</span>
<NotificationCenter
notifications={notifications}
onMarkRead={markRead}
onMarkAllRead={() =>
setNotifications((current) =>
current.map((notification) => ({ ...notification, read: true }))
)
}
onClear={() => setNotifications([])}
onOpenNotification={(notification) => markRead(notification.id)}
/>
</div>
<p className="text-sm text-muted-foreground">
{unreadCount(notifications)} unread of {notifications.length}.
</p>
</div>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| notifications | Notification[] | — | Newest first; the component does not sort them. |
| onMarkRead | (id: string) => void | — | Called when an unread row is clicked, before onOpenNotification. |
| onMarkAllRead | () => void | — | Adds the header action; it is hidden once nothing is unread. |
| onClear | () => void | — | Adds a "Clear all" row at the foot of the popover. |
| onOpenNotification | (notification: Notification) => void | — | Called with the row that was clicked, read or not. |
| trigger | React.ReactNode | a ghost bell button | Replaces the bell; a single element, which still gets the trigger's name and its own classes. |
| emptyMessage | React.ReactNode | Nothing new. | Shown when the tab being looked at has no rows. |
| align | "start" | "end" | "end" | Which edge of the trigger the popover lines up with. |
| className | string | — | Classes for the default bell button; a trigger of your own carries its own, so this is ignored then. The remaining props go on the popover trigger, which merges them into whichever element it renders. |
| renderLink | (href: string, props: React.ComponentProps<"a">) => React.ReactNode | a plain anchor | Swaps the anchor a row with an href renders for a router link; rows without an href stay buttons and never reach it. |
| Notification.id | string | — | Unique; what onMarkRead reports. |
| Notification.title | React.ReactNode | — | What happened, in one line. |
| Notification.description | React.ReactNode | — | Up to two quiet lines under the title. |
| Notification.time | Date | string | — | When it happened; rendered as a live RelativeTime. |
| Notification.read | boolean | — | Unread rows carry a primary dot. |
| Notification.type | "default" | "success" | "warning" | "danger" | "info" | "default" | Colours the row icon; ignored when the row has an avatar. |
| Notification.avatar | { name: string; src?: string } | — | Shown instead of the type icon — who the notification is about. |
| Notification.href | string | — | Makes the row a link; the click handlers still run. |
Dependencies
Source
"use client"
import * as React from "react"
import {
BellIcon,
CircleAlertIcon,
CircleCheckIcon,
InfoIcon,
TriangleAlertIcon,
} from "lucide-react"
import { cn } from "@/lib/utils"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from "@/components/ui/popover"
import { RelativeTime } from "@/components/ui/relative-time"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Separator } from "@/components/ui/separator"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { getInitials } from "@/lib/format"
/**
* Swaps the plain anchor a row with an href renders for a router link.
* Declared here rather than imported from header-bar so this file installs on
* its own; the shape is the same.
*/
export type RenderLink = (href: string, props: React.ComponentProps<"a">) => React.ReactNode
/** What kind of thing happened; it colours the row's icon, nothing else. */
export type NotificationTone = "default" | "success" | "warning" | "danger" | "info"
export type Notification = {
id: string
title: React.ReactNode
description?: React.ReactNode
time: Date | string
read: boolean
type?: NotificationTone
/** Shown instead of the type icon — who the notification is about. */
avatar?: { name: string; src?: string }
/** Makes the row a link; the click handlers still run. */
href?: string
}
// The popover trigger is the root — Base UI merges its props into whichever
// element `render` supplies, so a caller's id or data-* reaches the bell (or
// their own trigger) rather than being dropped. className is re-declared as a
// plain string and only dresses the default bell.
export type NotificationCenterProps = Omit<
React.ComponentProps<typeof PopoverTrigger>,
"className" | "render" | "children" | "aria-label"
> & {
notifications: Notification[]
onMarkAllRead?: () => void
onMarkRead?: (id: string) => void
/** Adds a "Clear all" action at the foot of the list. */
onClear?: () => void
onOpenNotification?: (notification: Notification) => void
/** Replaces the bell button; a single element, which still gets the trigger's accessible name. */
trigger?: React.ReactNode
emptyMessage?: React.ReactNode
align?: "start" | "end"
/**
* What "now" is when each row is dated. Defaults to the machine clock; a
* page whose figures are fixed to a reference date passes that date, so the
* ages stay in step with the numbers beside them.
*/
now?: Date
/** Classes for the default bell button; a trigger of your own carries its own. */
className?: string
renderLink?: RenderLink
}
/** How many of these are still unread. */
export function unreadCount(notifications: { read: boolean }[]): number {
return notifications.reduce((total, notification) => total + (notification.read ? 0 : 1), 0)
}
const TONE_ICON: Record<NotificationTone, React.ComponentType<{ className?: string }>> = {
default: BellIcon,
success: CircleCheckIcon,
warning: TriangleAlertIcon,
danger: CircleAlertIcon,
info: InfoIcon,
}
const TONE_CLASS: Record<NotificationTone, string> = {
default: "bg-muted text-muted-foreground",
success: "bg-success-muted text-success",
warning: "bg-warning-muted text-warning",
danger: "bg-danger-muted text-danger",
info: "bg-info-muted text-info",
}
export type NotificationMarkProps = { notification: Notification }
/** The avatar of whoever it is about, or a tinted circle carrying the type icon. */
function NotificationMark({ notification }: NotificationMarkProps) {
if (notification.avatar) {
return (
// Decorative: the title already says who it is about.
<Avatar size="sm" aria-hidden="true" className="mt-0.5">
{notification.avatar.src ? <AvatarImage src={notification.avatar.src} alt="" /> : null}
<AvatarFallback>{getInitials(notification.avatar.name)}</AvatarFallback>
</Avatar>
)
}
const tone = notification.type ?? "default"
const Icon = TONE_ICON[tone]
return (
<span
data-slot="notification-center-mark"
data-tone={tone}
aria-hidden="true"
className={cn(
"mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full",
TONE_CLASS[tone]
)}
>
<Icon className="size-3.5" />
</span>
)
}
/** The bell that carries the unread count, and the list of what happened. */
function NotificationCenter({
notifications,
onMarkAllRead,
onMarkRead,
onClear,
onOpenNotification,
trigger,
emptyMessage = "Nothing new.",
align = "end",
now,
className,
renderLink,
...props
}: NotificationCenterProps) {
const unread = unreadCount(notifications)
const unreadOnly = notifications.filter((notification) => !notification.read)
function open(notification: Notification) {
// Re-marking a row that is already read would push a no-op update into
// whatever state the caller keeps.
if (!notification.read) onMarkRead?.(notification.id)
onOpenNotification?.(notification)
}
function list(rows: Notification[], label: string) {
if (rows.length === 0) {
return (
<p
data-slot="notification-center-empty"
className="px-3 py-10 text-center text-sm text-muted-foreground"
>
{emptyMessage}
</p>
)
}
return (
// COUPLED TO registry/vibra/ui/scroll-area.tsx: the primitive's viewport
// is `size-full` and Base UI gives it `overflow: scroll`, so the height
// cap has to reach the viewport — a max-height on the root alone leaves
// a long list spilling past it. If the primitive starts taking a
// viewport className, pass it there instead of through the slot.
// Named while it overflows, since then it is a tab stop: an unnamed one
// was a stop a screen reader could say nothing about.
<ScrollArea aria-label={label} className="max-h-96 *:data-[slot=scroll-area-viewport]:max-h-96">
<div className="divide-y">
{rows.map((notification) => {
const content = (
<>
<NotificationMark notification={notification} />
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="truncate text-sm font-medium">{notification.title}</span>
{notification.description ? (
<span className="line-clamp-2 text-xs text-muted-foreground">
{notification.description}
</span>
) : null}
<RelativeTime
date={notification.time}
now={now}
className="text-xs text-muted-foreground"
/>
</span>
{notification.read ? null : (
<>
<span
data-slot="notification-center-unread-dot"
aria-hidden="true"
className="mt-2 size-1.5 shrink-0 rounded-full bg-primary"
/>
<span className="sr-only">Unread</span>
</>
)}
</>
)
const rowProps = {
"data-slot": "notification-center-row",
"data-read": notification.read || undefined,
onClick: () => open(notification),
className:
"flex w-full items-start gap-2.5 px-3 py-2.5 text-start outline-none transition-colors hover:bg-muted/60 focus-visible:bg-muted/60",
} as const
if (!notification.href) {
return (
<button key={notification.id} type="button" {...rowProps}>
{content}
</button>
)
}
// data-* keys are a JSX affordance rather than part of
// ComponentProps<"a">, so the row's own attributes are asserted
// onto the anchor props once, here.
const linkProps = { ...rowProps, children: content } as React.ComponentProps<"a">
return (
// renderLink returns a node, not necessarily a keyed element, so
// the key lives on a fragment around whatever comes back.
<React.Fragment key={notification.id}>
{renderLink ? (
renderLink(notification.href, linkProps)
) : (
<a href={notification.href} {...linkProps} />
)}
</React.Fragment>
)
})}
</div>
</ScrollArea>
)
}
return (
<Popover>
<PopoverTrigger
data-slot="notification-center"
// The bell is a picture and the count is a bare numeral, so the name
// says both — "Notifications, 2 unread".
aria-label={unread > 0 ? `Notifications, ${unread} unread` : "Notifications"}
render={
// A caller-supplied trigger has to be a single element for Base UI
// to merge the trigger's props into; the bell below is the default.
(trigger as React.ReactElement) ?? (
<Button
type="button"
variant="ghost"
size="icon"
className={cn("relative", className)}
>
<BellIcon />
{unread > 0 ? (
<span
data-slot="notification-center-count"
aria-hidden="true"
className="absolute -top-0.5 -end-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-avatar leading-none font-medium text-primary-foreground tabular-nums"
>
{unread > 9 ? "9+" : unread}
</span>
) : null}
</Button>
)
}
{...props}
/>
<PopoverContent align={align} className="w-88 gap-0 p-0">
{/* The panel keeps its own root inside the popup rather than renaming
the popup's slot: calendar.tsx styles itself off
[data-slot=popover-content], and taking that name away would break
anything composed in here that reads it. */}
<div data-slot="notification-center-content" className="flex flex-col">
<Tabs defaultValue="all" className="gap-0">
<div className="flex flex-col gap-2 p-2.5 pb-2">
<div className="flex items-center justify-between gap-2 ps-0.5">
<PopoverTitle className="text-sm font-medium">Notifications</PopoverTitle>
{onMarkAllRead && unread > 0 ? (
<Button
type="button"
variant="ghost"
size="xs"
className="-me-1 text-muted-foreground"
onClick={onMarkAllRead}
>
Mark all as read
</Button>
) : null}
</div>
<TabsList className="w-full">
<TabsTrigger value="all">All</TabsTrigger>
<TabsTrigger value="unread" className="gap-1.5">
Unread
{unread > 0 ? <Badge variant="secondary">{unread}</Badge> : null}
</TabsTrigger>
</TabsList>
</div>
<Separator />
<TabsContent value="all">{list(notifications, "All notifications")}</TabsContent>
<TabsContent value="unread">{list(unreadOnly, "Unread notifications")}</TabsContent>
</Tabs>
{onClear && notifications.length > 0 ? (
<>
<Separator />
<Button
type="button"
variant="ghost"
size="sm"
className="rounded-t-none text-muted-foreground"
onClick={onClear}
>
Clear all
</Button>
</>
) : null}
</div>
</PopoverContent>
</Popover>
)
}
export { NotificationCenter, NotificationMark }