Notification Center React component for Tailwind CSS v4
v1.0.0Added in 1.0.0
A React notification center: a bell with an unread count that opens an inbox with tabs, day groups, inline actions and swipe to dismiss.
Click the bell (or press its shortcut) and the panel grows out of it with All / Unread / Mentions tabs, the Unread tab counting live. Notifications sit under sticky day labels (Today, Yesterday, weekdays, then dates) with relative times that refresh every minute. Click a row to open it: it turns read, follows its link and the panel closes. Inline buttons (accept an invite, retry a payment) spin while their promise runs. The ⋯ menu on each row (on hover or focus with a mouse, always visible on touch) marks it read or unread or dismisses it, and the row collapses out. "Mark all as read" clears every dot at once and the red count on the bell scales away; when a new notification arrives the bell swings and the row slides in, and screen readers hear it through a polite live region. Keyboard: Tab into the panel, Down or Home jumps to the first row, Up / Down move between rows, Home / End jump, Enter opens, Escape closes and focus returns to the bell. The last row loads more by itself when it scrolls into view. On a phone the panel is a full-height bottom sheet: swipe a row left to dismiss it (a red Dismiss layer follows your finger) or right to toggle read, with a haptic tick where supported; drag the handle down to close.
Notification Center is a Pro React component in the React feedback components category, written in TypeScript for React 19 and Tailwind CSS v4. It installs as source with npx wingo-ui@latest add notification-center, together with Avatar, Badge, Button, Drawer, Dropdown Menu and 12 more and the npm packages @radix-ui/react-popover, lucide-react and motion.
Add to a project
Requirements: React 19 and Tailwind CSS v4, in Next.js (App Router or Pages), Vite or React Router. Installation guide · Next.js setup · Vite setup
npx wingo-ui@latest add notification-centerNotification Center is part of Pro, with 298 more Pro items: $8 a month or $150 once. 14-day refund before you download any Pro item. See plans
Usage
Import NotificationCenter from @/components/ui/notification-center.
import { CreditCard } from "lucide-react"
import { NotificationCenter, NotificationList, type NotificationItem } from "@/components/ui/notification-center"
const notifications: NotificationItem[] = [
{
id: "n1",
type: "mention",
name: "Sophia Lee",
avatar: "/demo/ioana-dumitrescu.jpg",
title: "Sophia Lee mentioned you in “Acme Corp quote”",
body: "@Emma Carter can you check the discount before we send it?",
createdAt: "2026-09-23T10:42:00",
href: "/quotes/Q-2026-118",
},
{
id: "n2",
icon: <CreditCard />,
tone: "danger",
title: "Your Pro plan payment failed",
body: "The card ending in 4821 was declined.",
createdAt: "2026-09-23T08:15:00",
actions: [{ label: "Retry", onSelect: () => fetch("/api/billing/retry", { method: "POST" }) }],
},
]
export function TopbarNotifications() {
return (
<NotificationCenter
notifications={notifications}
onRead={(ids) => console.log("read", ids)}
onDismiss={(id) => console.log("dismiss", id)}
/>
)
}
// the panel without the bell (full inbox page, sidebar panel): same content props, a header when title is set.
// it paints on var(--background); on a raised panel set [--nc-surface:var(--surface)] in className
export function InboxPage() {
return <NotificationList title="Notifications" notifications={notifications} className="rounded-3xl border border-border" />
}Props
notificationsdefault"[]"NotificationItem[]Newest first. { id, title: ReactNode, body?, createdAt: Date | string | number, read?, type?, avatar?, name?, icon?, tone?, href?, actions?: NotificationAction[] }. NotificationAction is { label, onSelect: () => void | Promise<unknown>, variant?: "solid" | "soft" | "ghost", tone? }. String dates are parsed; invalid ones go last without a time. type "mention" feeds the Mentions tab, and @Name inside string titles and bodies is highlighted.
onRead(ids: string[]) => voidRows turned read: from the row menu, a swipe right, opening a row or Mark all (when onReadAll is not set). The panel updates at once and keeps the change until your data agrees.
onUnread(ids: string[]) => voidRows turned unread from the row menu or a swipe right on a read row.
onReadAll() => void"Mark all as read" in the header (shown while anything is unread). Without it onRead gets every unread id.
onDismiss(id: string) => voidA row was dismissed (row menu or swipe left). Runs after the row has collapsed out, so remove it from your data here.
onSelect(item: NotificationItem) => voidA row was opened: it turns read, navigates to its href through NavLink and the panel closes.
filtersNotificationFilter[]The tabs, All / Unread / Mentions by default: { value, label, filter?: (item) => boolean, badge?, emptyTitle?, emptyText? }. A tab whose value is "unread" shows the unread count; each tab can have its own empty state. Fewer than two filters hide the tabs.
filterstringThe active tab, controlled. Pair it with onFilterChange.
defaultFilterdefault"all"stringThe tab it starts on while uncontrolled (a filter value).
onFilterChange(filter: string) => voidFires when another tab is picked.
titledefault"Notifications"ReactNodeThe panel title; it also names the dialog.
markAllLabeldefault"Mark all as read"stringThe header button that clears every unread dot.
emptyTitledefault"You're all caught up"stringThe empty state of a tab with nothing in it. A filter's own emptyTitle wins.
emptyTextdefault""stringA second, muted line under the empty title.
emptyIconReactNodeReplaces the BellOff icon of the empty state.
groupByDaydefaulttruebooleanSticky day labels: Today, Yesterday, weekday names this week, then dates in the locale. Off, rows show the date with the time.
timeFormatdefault"relative""relative" | "time""5 min ago" through Intl.RelativeTimeFormat, refreshed every minute while open, or the clock time ("14:20"). Under an older day label rows show the clock time, so "Yesterday" is never said twice. Relative times are computed after mount: the server and hydration render print the clock time, so there is no mismatch.
tonedefault"neutral""neutral" | "brand" | "success" | "warning" | "danger" | "info"The unread dots and the tab indicator.
badgeTonedefault"danger""neutral" | "brand" | "success" | "warning" | "danger" | "info"The count badge on the bell. Red by default on purpose: people read a red count as unread.
maxdefault99numberCounts above it read "99+".
widthdefault400numberThe desktop panel width in px (never wider than the screen minus 24px).
badgeRingColordefault"var(--bar-bg, var(--background))"stringThe 2px ring that cuts the badge out of the bell: the surface the bell sits on.
countdefault""numberOverrides the badge number (default: the unread count). 0 hides the badge. The playground's empty control means left out.
loadingdefaultfalsebooleanSkeleton rows while the list is empty, a spinner row after the last one otherwise.
hasMoredefaultfalsebooleanShows the "Load more" row at the end, which also loads by itself when it scrolls into view.
onLoadMore() => (void | Promise<unknown>)Loads the next page. A returned promise keeps a spinner in the row until it settles.
defaultOpendefaultfalsebooleanStarts open (uncontrolled).
openbooleanControlled open state. Pair it with onOpenChange.
onOpenChange(open: boolean) => voidFires when the panel opens or closes.
ringOnNewdefaulttruebooleanThe bell swings from its top when the unread count goes up (never under reduced motion).
swipeActionsdefaulttruebooleanSwipe a row left to dismiss it, right to toggle read. Follows the finger and commits past a quarter of the row or on a flick.
responsivedefaulttruebooleanBelow 768px the panel is a full-height bottom sheet with full-width tabs.
sidedefault"bottom""top" | "right" | "bottom" | "left"Where the desktop panel opens; it flips when there is no room.
aligndefault"end""start" | "center" | "end"The panel's alignment against the bell. end suits a bell at the right of a top bar.
shortcutdefault""stringA key combo in mod+k notation that opens and closes the panel from anywhere (none by default).
localedefault""stringDates, weekdays and relative times. Empty uses the LocaleProvider or the browser.
settingsHrefdefault""stringAdds a gear link in the header (a NavLink), e.g. to notification settings.
onSettings() => voidAdds a gear button in the header, or runs before the settingsHref link.
triggerReactNodeA custom trigger instead of the bell, rendered with asChild (one element that takes a ref).
linkComponent(props: LinkComponentProps) => ReactNodeThe router link for rows with href and the settings link; default from LinkProvider.
containerHTMLElement | nullPortal target for the panel and the sheet (default document.body).
labels{ trigger?: (unread) => string; markRead?; markUnread?; dismiss?; more?; loadMore?; loading?; settings?; filters?; unread?; earlier?; newNotification?: (title) => string }Every other string, English by default: the bell's label ("Notifications, 3 unread"), the row menu, the swipe layers, load more, the live announcement for new notifications.
classNamesPartial<Record<"trigger" | "badge" | "content" | "header" | "title" | "markAll" | "settings" | "tabs" | "list" | "group" | "groupLabel" | "item" | "media" | "body" | "time" | "actions" | "empty" | "footer", string>>Extra classes for the inner parts.
classNamestringExtra classes for the bell button. Other props (aria-*, data-*, handlers) go to the bell too.
Parts
NotificationList
componentThe inbox without the bell, for a full notifications page or a sidebar panel: the same tabs, day groups, rows, actions, empty state and load more. Takes every list prop of NotificationCenter (notifications, filters, onRead, onDismiss, labels...) plus div props.
NotificationItem
typeOne notification: { id, title, body?, createdAt, read?, type?, avatar?, name?, icon?, tone?, href?, actions? }. type "mention" feeds the Mentions tab.
NotificationAction
typeAn inline button on a row: { label, onSelect, variant?, tone? }. A returned promise keeps it spinning until it settles.
NotificationFilter
typeA tab: { value, label, filter?, badge?, emptyTitle?, emptyText? }.
Examples
Phone sheet with swipe actions
The full-height sheet a phone gets: swipe left to delete, right to mark read.
Empty states per tab
Each filter brings its own empty title and text; everything here is already read.
Custom filters
All / Invoices / Team with a count on the invoices tab.
Inbox page
NotificationList on its own, with a settings link and pages that load as you scroll.
Time format and locale
Relative US times next to 24-hour British clock times.
Tones
Brand and info unread dots and badges on a raised bar.
Loading
Skeleton rows on the first load, then the list fades in.
Dependencies
Files
- components/ui/notification-center.tsx
FAQ
Is the React Notification Center component free?
The preview, props and docs are free; copying the source needs a Pro plan. See plans.
How do I install Notification Center?
Run npx wingo-ui@latest add notification-center. It also adds the Wingo UI items it builds on (Avatar, Badge, Button, Drawer, Dropdown Menu and 12 more) and the npm packages @radix-ui/react-popover, lucide-react and motion. With shadcn, add the @wingo-ui registry with your API key, then run npx shadcn@latest add @wingo-ui/notification-center.
How does Notification Center behave on phones?
Below 768px the panel is a full-height bottom sheet with full-width tabs. The responsive prop controls this and is on by default.
See also
Used in
More React feedback components
Articles using this component
- React Notification Center: Inbox, Bell and Toasts
Component guides8 min read