Skip to contentVibraUI
Navigation & layout

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-center

Needs the @vibra registry in your components.json — set it up once.

Examples

Props

PropTypeDefaultDescription
notificationsNotification[]—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.
triggerReact.ReactNodea ghost bell buttonReplaces the bell; a single element, which still gets the trigger's name and its own classes.
emptyMessageReact.ReactNodeNothing 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.
classNamestring—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.ReactNodea plain anchorSwaps the anchor a row with an href renders for a router link; rows without an href stay buttons and never reach it.
Notification.idstring—Unique; what onMarkRead reports.
Notification.titleReact.ReactNode—What happened, in one line.
Notification.descriptionReact.ReactNode—Up to two quiet lines under the title.
Notification.timeDate | string—When it happened; rendered as a live RelativeTime.
Notification.readboolean—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.hrefstring—Makes the row a link; the click handlers still run.

Dependencies

Source

components/ui/notification-center.tsx
"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 }