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
| Area | Behavior |
|---|---|
| Trigger | Ghost 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 |
| Badge | unseenCount when passed; otherwise the unread item count. onOpenChange(true) is the host’s cue to clear it. The kit never writes the badge |
| Shell | Desktop Popover (align="end") · mobile Sheet. Unmounts on close |
| Filters | All / Unread only, with counts. No severity tabs — this is not Message Popover |
| Groups | item.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 |
| Row | Unread: 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" |
| Actions | First two actions inline (outline). A third or later action moves to overflow, together with Mark as read/unread and Dismiss |
| Mark all as read | Header link; disabled when nothing is unread |
| Async | No items + pending → skeleton rows. No items + error → Result + Retry. Success + empty → Empty. Refetch with items keeps the rows |
| Avatar vs icon | Mutually 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-centerFUI_PLUS_REGISTRY_TOKEN=xxx npx shadcn@latest add @f-ui-plus/notification-centerFUI_PLUS_REGISTRY_TOKEN=xxx yarn dlx shadcn@latest add @f-ui-plus/notification-centerFUI_PLUS_REGISTRY_TOKEN=xxx bun x shadcn@latest add @f-ui-plus/notification-centerAlso 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>
);
}Inline Actions and Footer
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
| Prop | Type | Default | Description |
|---|---|---|---|
items | readonly NotificationItem[] | — | Required. Host order is preserved. Newest-first is the host’s job |
unseenCount | number | unread item count | Trigger badge. Pass this and clear it on open — the kit never mutates it |
open | boolean | uncontrolled | Controlled open state |
onOpenChange | (open: boolean) => void | — | Fires on open and close. Host cue to clear unseenCount when open is true |
filter | NotificationFilter | uncontrolled | Controlled tab: "all" or "unread" |
defaultFilter | NotificationFilter | "all" | Uncontrolled initial tab |
onFilterChange | (filter: NotificationFilter) => void | — | Tab change |
onNavigate | (item: NotificationItem) => void | — | Title becomes the jump control. Shell closes, then the handler runs |
onMarkRead | (id: string, read: boolean) => void | — | Overflow Mark as read / unread. read is the desired state, not a toggle |
onMarkAllRead | () => void | — | Header action. Disabled when the unread count is 0 |
onDismiss | (id: string) => void | — | Overflow Dismiss. Removes the row only — does not complete the work |
pending | boolean | false | Skeleton rows when there are no items |
error | boolean | false | Error Result when there are no items. With items, rows still render |
onRetry | () => void | — | Outline Retry on the error Result |
footer | ReactNode | — | e.g. a View all notifications row |
trigger | ReactNode | ghost Bell + Count Badge | Replaces the default trigger. Wire accessible name and badge yourself |
renderEmpty | (ctx: NotificationEmptyContext) => ReactNode | — | Full Empty takeover (wins over slot props) |
emptyTitle | ReactNode | i18n by filter | Slot override for default Empty |
emptyDescription | ReactNode | i18n by filter | Slot override for default Empty |
emptyIcon | ReactNode | — | Slot override for default Empty |
emptyAction | ReactNode | — | Slot override for default Empty |
className | string | — | Root wrapper |
id | string | — | Root 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:
| Helper | Role |
|---|---|
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_KEY | Key for the trailing ungrouped section |
notificationCenterEn | Built-in English copy |