f-ui
Components

Notification Center

Bell trigger with an on-demand notification panel — unseen count, unread filter, grouped sections, inline actions with overflow, and Loading/Empty/Error states.

Plus Registry

This component ships from registry.plus.json, not the public registry.json. Set up @f-ui-plus on the Installation page, then install @f-ui-plus/notification-center.

Notification Center is the shell bell plus an on-demand panel for activity elsewhere that concerns this user. Desktop uses a Popover; mobile uses a bottom Sheet. Industry twins: SAP Fiori notification center, Atlassian notification drawer, Carbon notification panel.

It is not a Message Popover replacement — object messages are about the thing on screen and die when the object is saved or you navigate away. It is not toast — transient “Saved” feedback is host-owned (sonner) and dies in seconds. Class map: Messaging Surfaces.

When To Use

  • Surface cross-app activity that outlives the current page (approvals waiting, an export finished, a mention).
  • Keep a stable bell in the shell even when the list is empty — operators need a place to look.
  • Offer one-step row actions (Approve, Download) and send multi-step work to the object page via the title.

When Not To Use

  • Do not use Notification Center as a Message Popover replacement. Object messages die with the object; notification rows outlive the page. Put object readiness in the footer toolbar, not on a bell.
  • Do not use it as toast. “Saved” / “Copied” belongs to host toast and must not land in this panel.
  • Unseen ≠ unread. The badge counts what arrived since the panel was last opened, and the host clears it on open. Read state is per item and is cleared by the user. Do not drive one from the other — the kit never mutates the badge.
  • Dismissing a notification does not complete the underlying work. The row disappears; the approval, export, or task behind it still needs processing.
  • Inline actions are one-step only. Approve / Reject / Download. Anything that needs a form, a second confirm, or a workflow belongs on the object page.
  • Do not use this panel for form validation or as a Task / Inbox application. Pick the class on Messaging Surfaces.

Features

AreaBehavior
TriggerGhost bell with a Tooltip. Renders at zero items. Accessible name carries the count (Notifications, 3 new — "new", because the badge counts unseen, not unread). Nested Count Badge is decorative — no label
BadgeunseenCount when passed; otherwise the unread item count. onOpenChange(true) is the host’s cue to clear it. The kit never writes the badge
ShellDesktop Popover (align="end") · mobile Sheet. Unmounts on close
FiltersAll / Unread only, with counts. No severity tabs — this is not Message Popover
Groupsitem.group headings as Label (count). Host order is preserved; the kit never sorts. When some items are grouped, the leftovers trail as Other (count); when nothing is grouped, the list is flat with no heading
RowUnread: dot + sr-only “Unread”. Title is the navigate control and closes the panel. Description clamps to two lines. Meta is source · timestamp. StatusTag only when priority === "high"
ActionsFirst two actions inline (outline). A third or later action moves to overflow, together with Mark as read/unread and Dismiss
Mark all as readHeader link; disabled when nothing is unread
AsyncNo items + pending → skeleton rows. No items + errorResult + Retry. Success + empty → Empty. Refetch with items keeps the rows
Avatar vs iconMutually exclusive. Avatar wins when both are set (human-authored vs system-authored)

Copy guidance (not runtime truncation): lead the title with the action or outcome and keep it under ~40 characters (Approve PO 990123). Keep the description under ~80 characters. Format timestamp on the host — the kit never formats dates. Pass items newest-first.

Installing

Configure @f-ui-plus and FUI_PLUS_REGISTRY_TOKEN as in Installation — Plus Registry.

FUI_PLUS_REGISTRY_TOKEN=xxx pnpm dlx shadcn@latest add @f-ui-plus/notification-center
FUI_PLUS_REGISTRY_TOKEN=xxx npx shadcn@latest add @f-ui-plus/notification-center
FUI_PLUS_REGISTRY_TOKEN=xxx yarn dlx shadcn@latest add @f-ui-plus/notification-center
FUI_PLUS_REGISTRY_TOKEN=xxx bun x shadcn@latest add @f-ui-plus/notification-center

Also works via URL: https://ui.isaacfei.com/api/plus/r/notification-center.json.

registryDependencies: utils, avatar, button, dropdown-menu, popover, sheet, skeleton, tabs, tooltip, https://ui.isaacfei.com/r/count-badge.json, https://ui.isaacfei.com/r/empty.json, https://ui.isaacfei.com/r/result.json, https://ui.isaacfei.com/r/status-tag.json. Runtime: lucide-react.

Usage

import { NotificationCenter } from "@/components/f-ui/notification-center/notification-center";

const [unseen, setUnseen] = useState(3);

<NotificationCenter
  items={items}
  unseenCount={unseen}
  onOpenChange={(open) => {
    if (open) setUnseen(0); // host clears the badge; unread rows stay unread
  }}
  onNavigate={(item) => openObject(item.id)}
  onMarkRead={(id, read) => setRead(id, read)}
  onMarkAllRead={() => markAllRead()}
  onDismiss={(id) => remove(id)}
/>;

Examples

Default Bell

The trigger stays mounted with a host-owned unseen count. Open it — this demo sets unseenCount to 0 in onOpenChange. Unread rows keep a disc until you mark as read. Dismiss removes the row only.

Host unseen: 3 · Unread: 3. Open the bell — this host clears unseen on open.

"use client";

import { useState } from "react";
import { toast } from "sonner";

import {
  NotificationCenter,
  unreadCount,
  type NotificationItem,
} from "@/components/f-ui/notification-center/notification-center";
import { Button } from "@/components/ui/button";
import { createDemoNotifications } from "@/demos/notification-center/demo-data";

function seedItems(): NotificationItem[] {
  return createDemoNotifications((actionId, item) => {
    toast.message(`${item.title}: ${actionId}`);
  });
}

export function NotificationCenterDemo() {
  const [items, setItems] = useState<NotificationItem[]>(seedItems);
  const [unseen, setUnseen] = useState(() => unreadCount(seedItems()));
  const [open, setOpen] = useState(false);

  const unread = unreadCount(items);

  return (
    <div className="bg-card rounded-xl border">
      <div className="flex h-12 items-center justify-between gap-3 border-b px-3">
        <p className="text-muted-foreground min-w-0 truncate text-sm">
          Host unseen: {unseen} · Unread: {unread}. Open the bell — this host
          clears unseen on open.
        </p>
        <div className="flex shrink-0 items-center gap-2">
          <Button
            type="button"
            variant="outline"
            onClick={() => {
              const next = seedItems();
              setItems(next);
              setUnseen(unreadCount(next));
              setOpen(false);
            }}
          >
            Reset
          </Button>
          <NotificationCenter
            items={items}
            unseenCount={unseen}
            open={open}
            onOpenChange={(next) => {
              setOpen(next);
              if (next) setUnseen(0);
            }}
            onNavigate={(item) => {
              toast.message(`Open: ${item.title}`);
            }}
            onMarkRead={(id, read) => {
              setItems((current) =>
                current.map((item) =>
                  item.id === id ? { ...item, read } : item,
                ),
              );
            }}
            onMarkAllRead={() => {
              setItems((current) =>
                current.map((item) => ({ ...item, read: true })),
              );
            }}
            onDismiss={(id) => {
              setItems((current) => current.filter((item) => item.id !== id));
            }}
          />
        </div>
      </div>
    </div>
  );
}

Loading, Empty, and Error

Switch Demo status. No items + pending paints skeleton rows (not Empty). No items + error paints Result with Retry. Success with zero items paints Empty. Async triad: Page Region Status.

"use client";

import { useMemo, useState } from "react";
import { toast } from "sonner";

import type { NotificationItem } from "@/components/f-ui/notification-center/notification-center";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import {
  createDemoNotifications,
  NotificationCenterFrame,
} from "@/demos/notification-center/demo-data";

type DemoStatus = "populated" | "loading" | "empty" | "error";

const STATUS_OPTIONS: { value: DemoStatus; label: string }[] = [
  { value: "populated", label: "Populated" },
  { value: "loading", label: "Loading" },
  { value: "empty", label: "Empty" },
  { value: "error", label: "Error" },
];

export function NotificationCenterStatesDemo() {
  const [status, setStatus] = useState<DemoStatus>("loading");
  const items = useMemo(
    () =>
      createDemoNotifications((actionId, item) => {
        toast.message(`${item.title}: ${actionId}`);
      }),
    [],
  );

  const populated = status === "populated";
  const list: readonly NotificationItem[] = populated ? items : [];

  return (
    <div className="flex flex-col gap-3">
      <label className="text-muted-foreground flex items-center gap-2 text-sm">
        <span className="whitespace-nowrap">Demo status</span>
        <Select
          value={status}
          onValueChange={(value) => setStatus(value as DemoStatus)}
        >
          <SelectTrigger aria-label="Demo status" className="w-32">
            <SelectValue />
          </SelectTrigger>
          <SelectContent>
            {STATUS_OPTIONS.map((opt) => (
              <SelectItem key={opt.value} value={opt.value}>
                {opt.label}
              </SelectItem>
            ))}
          </SelectContent>
        </Select>
      </label>
      <NotificationCenterFrame
        items={list}
        pending={status === "loading"}
        error={status === "error"}
        onRetry={() => setStatus("populated")}
        onNavigate={(item) => {
          toast.message(`Open: ${item.title}`);
        }}
      />
    </div>
  );
}

At most two actions sit inline. Review shipping hold has four, so the rest land in overflow with Mark as read and Dismiss. The footer is a host slot — here a View all notifications row.

"use client";

import { useMemo } from "react";
import { toast } from "sonner";

import { Button } from "@/components/ui/button";
import {
  createDemoNotifications,
  NotificationCenterFrame,
} from "@/demos/notification-center/demo-data";

export function NotificationCenterActionsDemo() {
  const items = useMemo(
    () =>
      createDemoNotifications((actionId, item) => {
        toast.message(`${item.title}: ${actionId}`);
      }),
    [],
  );

  return (
    <NotificationCenterFrame
      items={items}
      onNavigate={(item) => {
        toast.message(`Open: ${item.title}`);
      }}
      onMarkRead={() => {
        toast.message("Mark as read is host-owned");
      }}
      onDismiss={() => {
        toast.message("Dismiss removes the row only");
      }}
      footer={
        <Button
          type="button"
          variant="link"
          className="h-auto w-full justify-center p-0"
          onClick={() => toast.message("View all notifications")}
        >
          View all notifications
        </Button>
      }
    />
  );
}

Composition

NotificationCenter
├─ trigger (ghost Bell + decorative Count Badge)
└─ panel (Popover / Sheet)
   ├─ header (title + Mark all as read)
   ├─ tabs (All (n) · Unread (n))
   ├─ body
   │  ├─ pending, no items → skeleton rows
   │  ├─ error, no items → Result + Retry
   │  ├─ empty → Empty (copy by filter)
   │  └─ rows
   │     └─ group heading Label (count)
   │        └─ row (unread dot · avatar or icon · title · description · meta · actions)
   └─ footer (host slot)

API Reference

Props

NotificationCenter

PropTypeDefaultDescription
itemsreadonly NotificationItem[]Required. Host order is preserved. Newest-first is the host’s job
unseenCountnumberunread item countTrigger badge. Pass this and clear it on open — the kit never mutates it
openbooleanuncontrolledControlled open state
onOpenChange(open: boolean) => voidFires on open and close. Host cue to clear unseenCount when open is true
filterNotificationFilteruncontrolledControlled tab: "all" or "unread"
defaultFilterNotificationFilter"all"Uncontrolled initial tab
onFilterChange(filter: NotificationFilter) => voidTab change
onNavigate(item: NotificationItem) => voidTitle becomes the jump control. Shell closes, then the handler runs
onMarkRead(id: string, read: boolean) => voidOverflow Mark as read / unread. read is the desired state, not a toggle
onMarkAllRead() => voidHeader action. Disabled when the unread count is 0
onDismiss(id: string) => voidOverflow Dismiss. Removes the row only — does not complete the work
pendingbooleanfalseSkeleton rows when there are no items
errorbooleanfalseError Result when there are no items. With items, rows still render
onRetry() => voidOutline Retry on the error Result
footerReactNodee.g. a View all notifications row
triggerReactNodeghost Bell + Count BadgeReplaces the default trigger. Wire accessible name and badge yourself
renderEmpty(ctx: NotificationEmptyContext) => ReactNodeFull Empty takeover (wins over slot props)
emptyTitleReactNodei18n by filterSlot override for default Empty
emptyDescriptionReactNodei18n by filterSlot override for default Empty
emptyIconReactNodeSlot override for default Empty
emptyActionReactNodeSlot override for default Empty
classNamestringRoot wrapper
idstringRoot wrapper

NotificationFilter is "all" | "unread".

NotificationEmptyContext is { filter: NotificationFilter; total: number } (total is the unfiltered item count).

NotificationItem

{
  id: string;
  title: string; // lead with the action or outcome; ~40 characters
  description?: string; // ~80 characters; clamped to 2 lines
  source?: string; // product / app name
  timestamp?: string; // host-formatted display string
  read?: boolean; // omit or false → unread
  priority?: "high" | "medium" | "low"; // only "high" renders a marker
  group?: string; // section label, e.g. "Today"
  avatarSrc?: string;
  avatarInitials?: string;
  icon?: ReactNode; // system-authored; mutually exclusive with avatar fields
  actions?: readonly NotificationAction[];
}

NotificationAction is { id: string; label: string; onSelect: (item) => void; tone?: "default" | "destructive" }.

Helpers

Re-exported from the component entry:

HelperRole
unreadCount(items)Count of items that are not read: true
filterNotifications(items, filter)"unread" keeps unread rows; "all" copies the list. Host order preserved
groupNotifications(items)Stable host order; ungrouped items in a trailing section
splitNotificationActions(actions)First two inline; remainder overflow
NOTIFICATION_UNGROUPED_KEYKey for the trailing ungrouped section
notificationCenterEnBuilt-in English copy

On this page