# React Notification Center: Inbox, Bell and Toasts

> Build a React notification center with an unread bell, filter tabs, day groups and swipe to dismiss, and learn which events belong in the inbox or a toast.

- Author: [Serban Rusu](https://wingo-ui.com/blog/authors/serban), Founder of Wingo UI
- Published: Oct 19, 2026
- Category: [Component guides](https://wingo-ui.com/blog/category/components)
- Reading time: 8 min
- Canonical: https://wingo-ui.com/blog/react-notification-center

## TL;DR

A React notification center is a bell with an unread count that opens a persistent inbox: filter tabs, day groups, mark as read, inline actions and a way to dismiss rows. Every event that matters goes into the inbox, and only the ones that need attention right now also show a toast. The Wingo UI Notification Center takes a notifications array and read and dismiss callbacks, and turns into a full-height bottom sheet with swipe actions on phones.

A React notification center is the bell in your top bar and the inbox behind it: an unread count, a list of what happened, a way to mark things read and a way to get rid of them. Most notification panel React examples stop at a popover with a list. Then real data arrives: forty rows that need grouping, a mention buried under billing events, an invite that needs an Accept button, a phone where the popover covers half the screen. This guide covers when an event belongs in the inbox and when in a toast, the props that matter in the Wingo UI [Notification Center](https://wingo-ui.com/components/notification-center), syncing reads with a server, phones, accessibility and real-time updates.

## What is a React notification center?

It is a persistent, in-app record of events, opened from a bell. An in-app notification inbox does four jobs that a plain list does not:

1. **Counts.** A badge on the bell with the number of unread items, capped (99+), hidden at zero.
2. **Sorts.** Filter tabs (All, Unread, Mentions) and day groups (Today, Yesterday, then dates), newest first.
3. **Acts.** Open a row, accept an invite inline, mark read or unread, dismiss.
4. **Updates.** New rows slide in, the bell reacts, older pages load as you scroll.

## When does an event need a toast, the inbox or both?

Use a toast for feedback on something the user just did, and the inbox for anything they may need later. Courier's guide to [toasts in web apps](https://www.courier.com/blog/youre-not-github-toasts-are-probably-fine-for-your-app) puts it as a rule worth keeping: every important event goes to the notification center, and only some of them also get a toast. GitHub goes further: its [Primer accessibility guidance](https://primer.style/accessibility/toasts) says toasts pose significant accessibility concerns and recommends banners and dialogs instead. That is a reason to never put anything only in a toast.

| Event | Toast | Inbox |
|---|---|---|
| "Settings saved" after a click | Yes | No |
| A teammate mentions you | Yes, while the app is open | Yes |
| An invoice was paid overnight | No | Yes |
| A payment failed | Yes | Yes, with a Retry action |
| An export finished in the background | Yes, with an Open action | Yes |

For choosing the toast itself, see the [Sonner alternative comparison](https://wingo-ui.com/blog/sonner-alternative-react-toast); the free Wingo UI [Toast](https://wingo-ui.com/components/toast) is the one used in the examples below.

## What are the options for a React notification center?

You either use a hosted service that stores and delivers notifications for you, or a UI component you feed your own data. As of October 2026:

| Option | What it is | Where notifications live |
|---|---|---|
| [Novu Inbox](https://docs.novu.co/platform/inbox/react/components/bell) | `<Inbox>` component with a bell, configured with `applicationIdentifier` and `subscriber`; `renderBell` gets `unreadCount` | Novu's service |
| [Knock](https://docs.knock.app/in-app-ui/react/sdk/components/notification-icon-button) | React SDK with a `NotificationIconButton` whose `badgeCountType` is `unseen`, `unread` or `all` | Knock's service |
| [Wingo UI Notification Center](https://wingo-ui.com/components/notification-center) | Pro source component, `npx wingo-ui@latest add notification-center` | Your database, passed in as an array |

Pick Novu or Knock when you need delivery across email, push and in-app with user preferences: that is a backend problem a component does not solve. Pick a source component when your app already stores notifications.

## What props does the notification center need?

One array and the callbacks that change it. `notifications` is newest first; each item has an `id`, a `title`, a `createdAt` (Date, string or number) and optional `body`, `read`, `type`, `avatar` with `name`, or an `icon` with a `tone`, an `href` and inline `actions`.

```bash
npx wingo-ui@latest add notification-center
```

```tsx
"use client";

import { useState } from "react";
import { CreditCard } from "lucide-react";
import { NotificationCenter, type NotificationItem } from "@/components/ui/notification-center";

const initial: NotificationItem[] = [
  {
    id: "n1",
    type: "mention",
    name: "Sophia Lee",
    title: "Sophia Lee mentioned you in “Acme Corp quote”",
    body: "@Emma Carter can you check the discount before we send it?",
    createdAt: "2026-10-19T10: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-10-19T08:15:00",
    actions: [{ label: "Retry", onSelect: () => fetch("/api/billing/retry", { method: "POST" }) }],
  },
];

export function TopbarNotifications() {
  const [items, setItems] = useState(initial);

  return (
    <NotificationCenter
      notifications={items}
      onRead={(ids) => setItems((prev) => prev.map((n) => (ids.includes(n.id) ? { ...n, read: true } : n)))}
      onUnread={(ids) => setItems((prev) => prev.map((n) => (ids.includes(n.id) ? { ...n, read: false } : n)))}
      onDismiss={(id) => setItems((prev) => prev.filter((n) => n.id !== id))}
      settingsHref="/settings/notifications"
      locale="en-US"
    />
  );
}
```

`type: "mention"` feeds the Mentions tab, and `@Name` inside string titles and bodies is highlighted. An action whose `onSelect` returns a promise keeps its button spinning until it settles. Rows with an `href` render through the router link you set with `LinkProvider` or the `linkComponent` prop, so `next/link` works without the component importing it.

Open the bell in the demo, then press Simulate a notification: the bell swings and the row slides in.

> Live demo (Notification Center): Open the bell, mark a row read from its ⋯ menu, accept the invite, then press Simulate a notification and watch the count go up. Try it at [Notification Center](https://wingo-ui.com/components/notification-center) and install it with `npx wingo-ui@latest add notification-center`.

## How do read, unread and dismiss stay in sync with a server?

The panel updates at once and calls your handler; you save the change. The component keeps that local state until your `notifications` data agrees, so a slow request never makes a dot blink back on. `onReadAll` runs for the header button; without it, `onRead` gets every unread id. `onDismiss` fires after the row has collapsed out, which is the moment to remove it from your data.

```tsx
const save = (body: object) =>
  fetch("/api/notifications", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });

<NotificationCenter
  notifications={items}
  onRead={(ids) => save({ ids, read: true })}
  onUnread={(ids) => save({ ids, read: false })}
  onReadAll={() => save({ all: true, read: true })}
  onDismiss={(id) => {
    setItems((prev) => prev.filter((n) => n.id !== id));
    fetch(`/api/notifications/${id}`, { method: "DELETE" });
  }}
  hasMore={hasMore}
  onLoadMore={loadOlder}
/>
```

`hasMore` adds a Load more row that also loads by itself when it scrolls into view. Return a promise from `onLoadMore` and the row shows a spinner until it settles.

### Should the bell count unread or unseen?

The badge counts unread items by default. Some apps prefer unseen: the count resets when the panel opens, even if nothing was read. Knock exposes the same choice through `badgeCountType`. Here, `count` takes any number:

```tsx
const [seenAt, setSeenAt] = useState(0);
const unseen = items.filter((n) => !n.read && new Date(n.createdAt).getTime() > seenAt).length;

<NotificationCenter notifications={items} count={unseen} onOpenChange={(open) => open && setSeenAt(Date.now())} />
```

## How do filter tabs and day groups work?

`filters` replaces the default All / Unread / Mentions tabs. Each tab has a `value`, a `label`, an optional `filter` function, a `badge` and its own empty state. A tab whose value is `"unread"` shows the live unread count without a `badge`.

```tsx
filters={[
  { value: "all", label: "All" },
  { value: "billing", label: "Billing", filter: (n) => n.type === "billing", badge: unreadBilling },
  { value: "mentions", label: "Mentions", filter: (n) => n.type === "mention", emptyTitle: "No mentions" },
]}
```

Fewer than two filters hide the tab row. `groupByDay` (on by default) adds sticky labels: Today, Yesterday, weekday names this week, then dates in the `locale`. Times read "5 min ago" through `Intl.RelativeTimeFormat` and refresh every minute while the panel is open. Relative times are computed after mount and the server prints clock times, so there is no hydration mismatch.

## How does a notification panel work on a phone?

Below 768px the panel becomes a full-height bottom sheet with full-width tabs, because a 400px popover anchored to a small bell leaves no room on a 390px screen. Drag the handle down to close it. Swipe a row left to dismiss it or right to toggle read: a colored layer follows your finger, the swipe commits past a quarter of the row or on a flick, and the phone gives a short haptic tick where the browser supports vibration. The ⋯ menu is always visible on touch, so swiping is never the only way. `responsive={false}` keeps the popover, `swipeActions={false}` turns swiping off. The gesture is the same one covered in the [React swipe to delete guide](https://wingo-ui.com/blog/react-swipe-to-delete), and the sheet is the pattern from the [React bottom sheet guide](https://wingo-ui.com/blog/react-bottom-sheet-for-the-web).

## Is the notification center accessible?

The bell's label carries the count ("Notifications, 3 unread"), and it changes as the count does. New notifications that arrive while the component is mounted are announced through a polite live region ("New notification: ..."), and older pages from Load more are not announced as news. With the panel open, Down or Home jumps to the first row, Up and Down move between rows, End jumps to the last, Enter opens one, and Escape closes the panel and returns focus to the bell. The row menu shows on hover and on keyboard focus. `shortcut="alt+n"` opens the panel from anywhere, and every string lives in `labels` for translation.

## How do I add real-time notifications and toasts?

Stream new notifications into the same array, and toast only the urgent ones while the panel is closed. This example uses Server-Sent Events with the browser's `EventSource`; a WebSocket works the same way. Mount `<Toaster />` once in your root layout.

```tsx
"use client";

import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import { NotificationCenter, type NotificationItem } from "@/components/ui/notification-center";
import { toast } from "@/components/ui/toast";

export function LiveNotifications({ initial }: { initial: NotificationItem[] }) {
  const [items, setItems] = useState(initial);
  const [open, setOpen] = useState(false);
  const openRef = useRef(false);

  useEffect(() => {
    const source = new EventSource("/api/notifications/stream");
    source.onmessage = (event) => {
      const item = JSON.parse(event.data) as NotificationItem;
      setItems((prev) => (prev.some((n) => n.id === item.id) ? prev : [item, ...prev]));
      // the inbox always gets it; a toast only for mentions while the panel is closed
      if (item.type === "mention" && !openRef.current) {
        toast(item.title, { action: { label: "View", onClick: () => setOpen(true) } });
      }
    };
    return () => source.close();
  }, []);

  return (
    <NotificationCenter
      notifications={items}
      open={open}
      onOpenChange={(next) => {
        openRef.current = next;
        setOpen(next);
      }}
      onRead={(ids) => setItems((prev) => prev.map((n) => (ids.includes(n.id) ? { ...n, read: true } : n)))}
      onDismiss={(id) => setItems((prev) => prev.filter((n) => n.id !== id))}
      linkComponent={Link}
    />
  );
}
```

The duplicate check matters: a reconnecting stream can send the same event twice.

## Can I use just the bell, or a full notifications page?

Yes to both. The free [Notification Bell](https://wingo-ui.com/components/notification-bell) is a standalone react notification bell: an iOS-style count badge whose digits roll, a swing when the count goes up and a 44px hit area on touch. Use it with your own panel, or pass it to the center as its `trigger` with your unread count. For a `/notifications` page or a sidebar panel, `NotificationList` renders the same tabs, groups, rows and load more without the bell.

```tsx
import { NotificationBell } from "@/components/ui/notification-bell";
import { NotificationList } from "@/components/ui/notification-center";

<NotificationCenter notifications={items} trigger={<NotificationBell count={unread} size={40} />} />

<NotificationList title="Notifications" notifications={items} className="rounded-3xl border border-border" />
```

> Live demo (Notification Bell): Press plus a few times quickly: the bell swings harder and the badge rolls to the new number, then reads 99+ past the max. Try it at [Notification Bell](https://wingo-ui.com/components/notification-bell) and install it with `npx wingo-ui@latest add notification-bell`.

## What are the common notification center pitfalls?

- **Toast-only events.** If an event matters, it goes in the inbox too. Toasts time out.
- **Dropping the row before it collapses.** Remove it in `onDismiss`, which runs after the animation, not in your own swipe handler.
- **Unstable ids.** Reads and dismissals are tracked by `id`. Generate it on the server, never from the array index.
- **Announcing the first fetch.** The component treats the first non-empty list as the baseline, so a page load does not read forty titles aloud. Keep that behavior if you build your own.

## How do I add it to my project?

Run `npx wingo-ui@latest add notification-center`. The CLI copies the source into `components/ui`, adds the Wingo UI components, hooks and helpers it imports, and installs `@radix-ui/react-popover`, `motion` and `lucide-react`. The Notification Center is a Pro component ([pricing](https://wingo-ui.com/pricing)); the Notification Bell and the Toast are free. More single-component deep dives are in the [component guides category](https://wingo-ui.com/blog/category/components), and the [React form components guide](https://wingo-ui.com/blog/react-form-components-guide) applies the same touch, keyboard and screen reader rules to every form control.

## Components in this post

- [Notification Center](https://wingo-ui.com/components/notification-center) (Pro): A React notification center: a bell with an unread count that opens an inbox with tabs, day groups, inline actions and swipe to dismiss. Install: `npx wingo-ui@latest add notification-center`
- [Notification Bell](https://wingo-ui.com/components/notification-bell) (Free): A React notification bell with an iOS-style unread badge that rolls its digits on new alerts. Install: `npx wingo-ui@latest add notification-bell`
- [Toast](https://wingo-ui.com/components/toast) (Free): React toast notifications: mount one Toaster and call toast() from anywhere, with stacking, swipe to dismiss and a pause while you read. Install: `npx wingo-ui@latest add toast`

## FAQ

### What is the difference between a toast and a notification center?

A toast confirms something the user just did and disappears after a few seconds. A notification center keeps a record of what happened, including while the user was away, so they can read and act on it later. Send every important event to the inbox and only the urgent ones to a toast as well.

### Should the bell count unread or unseen notifications?

Unread counts drop as each notification is opened or marked read; unseen counts reset as soon as the panel opens. Knock lets you pick either for its bell through badgeCountType. The Wingo UI Notification Center counts unread by default, and its count prop takes any number, such as an unseen count.

### How do I get real-time notifications into a React inbox?

Open a WebSocket or a Server-Sent Events stream in an effect, prepend each new notification to your state and pass the array to the inbox. The component animates the new row, swings the bell and announces it to screen readers; your server decides what is sent.

### Do I need Novu or Knock to build a notification center?

Only if you want a hosted service to deliver and store notifications across channels such as email, push and in-app. If your backend already stores notifications, a UI component that takes an array is enough, and you keep your own data model.

### Is the Wingo UI Notification Center free?

The Notification Center is a Pro component. The standalone Notification Bell and the Toast are free.

---

Source: https://wingo-ui.com/blog/react-notification-center
