For the complete documentation index, see llms.txt. Markdown variants are available by appending .md to documentation URLs. Homepage markdown is available at /llms.md/content.md. An agent skill is available at /.well-known/agent-skills/site-skill.md.

Notification Center 01

A notification center with category filters, unread counts, and actions.

Installation

$ pnpm dlx shadcn@latest add @cobalt/notification-center-01

Pass notification data and handle actions with onAction. The selected category can be controlled with tab and onTabChange.

Source

"use client";

import {
  RiNotification3Line,
  RiNotificationOffLine,
  RiErrorWarningLine,
  RiCheckboxCircleLine,
  RiInformationLine,
} from "@remixicon/react";
import { cn } from "cn";
import * as React from "react";

import {
  Avatar,
  AvatarFallback,
  AvatarImage,
} from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
  Empty,
  EmptyDescription,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from "@/components/ui/empty";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";

function Count({ value }: { value: number }) {
  const [display, setDisplay] = React.useState(value);
  const current = React.useRef(value);
  React.useEffect(() => {
    const media = window.matchMedia("(prefers-reduced-motion: reduce)");
    const from = current.current;
    if (media.matches || from === value) {
      current.current = value;
      setDisplay(value);
      return;
    }
    const start = performance.now();
    let frame = 0;
    const tick = (now: number) => {
      const t = Math.min(1, (now - start) / 320);
      current.current = Math.round(from + (value - from) * (1 - (1 - t) ** 3));
      setDisplay(current.current);
      if (t < 1) {
        frame = requestAnimationFrame(tick);
      }
    };
    frame = requestAnimationFrame(tick);
    const reduce = () => {
      if (media.matches) {
        cancelAnimationFrame(frame);
        current.current = value;
        setDisplay(value);
      }
    };
    media.addEventListener("change", reduce);
    return () => {
      cancelAnimationFrame(frame);
      media.removeEventListener("change", reduce);
    };
  }, [value]);
  return <>{display}</>;
}

export type NotificationCenterTab = "all" | "mentions" | "system";
export interface NotificationCenterItem {
  id: string;
  category: "mentions" | "system" | "activity";
  group: string;
  title: string;
  description: string;
  timestamp: string;
  unread?: boolean;
  status?: "neutral" | "information" | "success" | "error";
  icon?: React.ComponentType<{ className?: string; "aria-hidden"?: boolean }>;
  avatar?: { src?: string; alt: string; initials?: string };
  actions?: {
    id: string;
    label: string;
    variant?: React.ComponentProps<typeof Button>["variant"];
  }[];
}

export function NotificationCenter({
  notifications,
  defaultTab = "all",
  tab,
  onTabChange,
  onAction,
  title = "Notifications",
  emptyMessage = "Nothing new right now.",
  className,
  ref,
  ...props
}: Omit<React.ComponentProps<"section">, "onChange"> & {
  notifications: NotificationCenterItem[];
  defaultTab?: NotificationCenterTab;
  tab?: NotificationCenterTab;
  onTabChange?: (tab: NotificationCenterTab) => void;
  onAction?: (notificationId: string, actionId: string) => void;
  title?: string;
  emptyMessage?: string;
}) {
  const [internalTab, setInternalTab] =
    React.useState<NotificationCenterTab>(defaultTab);
  const [readIds, setReadIds] = React.useState<Set<string>>(() => new Set());
  const activeTab = tab ?? internalTab;
  const unread = (item: NotificationCenterItem) =>
    item.unread && !readIds.has(item.id);
  const count = notifications.filter(unread).length;
  const visible =
    activeTab === "all"
      ? notifications
      : notifications.filter((item) => item.category === activeTab);
  const icons = {
    neutral: RiNotification3Line,
    information: RiInformationLine,
    success: RiCheckboxCircleLine,
    error: RiErrorWarningLine,
  };
  const statusClasses = {
    neutral:
      "bg-[var(--surface-strong,var(--muted))] text-muted-foreground dark:bg-accent",
    information:
      "bg-[var(--notification-information-background,var(--muted))] text-[var(--notification-information-foreground,var(--foreground))]",
    success:
      "bg-[var(--notification-success-background,var(--muted))] text-[var(--notification-success-foreground,var(--foreground))]",
    error:
      "bg-[var(--notification-error-background,var(--muted))] text-[var(--notification-error-foreground,var(--destructive))]",
  };
  return (
    <section
      ref={ref}
      aria-label={title}
      className={cn("@container w-full max-w-[430px]", className)}
      {...props}
    >
      <Card radius={24} size="flush" variant="sunken">
        <div className="flex flex-col gap-3 p-4 pb-1.5">
          <div className="flex items-start justify-between gap-4">
            <div className="flex min-w-0 flex-col gap-0.5">
              <h2 className="text-lg leading-6.5 font-medium">{title}</h2>
              <p className="text-sm leading-5 text-muted-foreground">
                {count === 0 ? (
                  "No unread notifications"
                ) : (
                  <>
                    <Count value={count} /> unread
                  </>
                )}
              </p>
            </div>
            <Button
              variant="soft"
              size="sm"
              disabled={count === 0}
              onClick={() =>
                setReadIds(
                  new Set(
                    notifications
                      .filter((item) => item.unread)
                      .map((item) => item.id)
                  )
                )
              }
            >
              Mark all read
            </Button>
          </div>
          <Tabs
            className="w-full"
            value={activeTab}
            onValueChange={(value) => {
              if (
                value === "all" ||
                value === "mentions" ||
                value === "system"
              ) {
                if (tab === undefined) {
                  setInternalTab(value);
                }
                onTabChange?.(value);
              }
            }}
          >
            <TabsList
              indicator
              variant="segmented"
              aria-label="Notification category"
              className="w-full"
            >
              {(
                [
                  ["all", "All"],
                  ["mentions", "Mentions"],
                  ["system", "System"],
                ] as const
              ).map(([id, label]) => (
                <TabsTrigger
                  key={id}
                  value={id}
                  count={
                    <Count
                      value={
                        id === "all"
                          ? notifications.length
                          : notifications.filter((item) => item.category === id)
                              .length
                      }
                    />
                  }
                >
                  {label}
                </TabsTrigger>
              ))}
            </TabsList>
          </Tabs>
        </div>
        <div className="px-2 pt-1 pb-2">
          <div>
            <div className="max-h-[516px] overflow-y-auto overscroll-contain">
              {visible.length === 0 ? (
                <Empty>
                  <EmptyHeader>
                    <EmptyMedia variant="icon">
                      <RiNotificationOffLine aria-hidden />
                    </EmptyMedia>
                    <EmptyTitle>{emptyMessage}</EmptyTitle>
                    <EmptyDescription>
                      Updates will appear here as they arrive.
                    </EmptyDescription>
                  </EmptyHeader>
                </Empty>
              ) : (
                <div className="flex flex-col gap-2">
                  {visible.map((item, index) => {
                    const status = item.status ?? "neutral";
                    const Icon = item.icon ?? icons[status];
                    return (
                      <div
                        key={`${activeTab}-${item.id}`}
                        style={
                          {
                            "--delay": `${index * 40}ms`,
                          } as React.CSSProperties
                        }
                        className="notification-center-01-enter"
                      >
                        <Card
                          radius={16}
                          size="sm"
                          variant="raised"
                          role="article"
                        >
                          <CardContent className="flex-row">
                            {item.avatar ? (
                              <Avatar size="lg">
                                {item.avatar.src && (
                                  <AvatarImage
                                    src={item.avatar.src}
                                    alt={item.avatar.alt}
                                  />
                                )}
                                <AvatarFallback>
                                  {item.avatar.initials ??
                                    item.avatar.alt.slice(0, 1)}
                                </AvatarFallback>
                              </Avatar>
                            ) : (
                              <span
                                className={cn(
                                  "flex size-10 shrink-0 items-center justify-center rounded-full",
                                  statusClasses[status]
                                )}
                              >
                                <Icon className="size-5" aria-hidden />
                              </span>
                            )}
                            <div className="flex min-w-0 flex-1 flex-col gap-0.5">
                              <div className="flex min-w-0 items-start justify-between gap-3">
                                <p className="min-w-0 [overflow-wrap:anywhere] text-sm leading-5 font-medium">
                                  {item.title}
                                </p>
                                <div className="flex shrink-0 items-center gap-2">
                                  <span className="hidden text-xs leading-4 font-medium text-muted-foreground @min-[300px]:inline">
                                    {item.timestamp}
                                  </span>
                                  {unread(item) && (
                                    <span
                                      className="size-2 rounded-full bg-primary"
                                      aria-label="Unread"
                                    />
                                  )}
                                </div>
                              </div>
                              <p className="[overflow-wrap:anywhere] text-sm leading-5 text-muted-foreground">
                                {item.description}
                              </p>
                              {!!item.actions?.length && (
                                <div className="mt-1.5 flex flex-wrap items-center gap-2">
                                  {item.actions.map((action) => (
                                    <Button
                                      key={action.id}
                                      size="sm"
                                      variant={action.variant ?? "outline"}
                                      onClick={() => {
                                        setReadIds((current) =>
                                          new Set(current).add(item.id)
                                        );
                                        onAction?.(item.id, action.id);
                                      }}
                                    >
                                      {action.label}
                                    </Button>
                                  ))}
                                </div>
                              )}
                            </div>
                          </CardContent>
                        </Card>
                      </div>
                    );
                  })}
                </div>
              )}
            </div>
          </div>
        </div>
      </Card>
    </section>
  );
}