Components · Collaboration
NotificationCenter
Bell with an unread count and a panel: All and Unread tabs, day groups, mark read, load more and an empty state.
Bell and panel
import { useState } from 'react';
import { Link } from '@rookcom/monarch-react';
import { NotificationCenter, type NotificationItem } from '@rookcom/monarch-react/collaboration';
const NOW = '2026-03-02T12:00:00Z';
const SAMPLE: NotificationItem[] = [
{
id: 'n1',
title: 'Ana Lopez mentioned you in Launch checklist',
description: '“Can you confirm the legal sign-off date?”',
time: '2026-03-02T11:42:00Z',
actor: { name: 'Ana Lopez' },
},
{
id: 'n2',
title: 'Ben Okafor assigned you Update pricing page',
time: '2026-03-02T09:15:00Z',
actor: { name: 'Ben Okafor' },
},
{
id: 'n3',
title: 'Chloé Martin replied to your comment',
description: '“Agreed, let’s ship the shorter version.”',
time: '2026-03-02T08:03:00Z',
actor: { name: 'Chloé Martin' },
read: true,
},
{
id: 'n4',
title: 'Your weekly export is ready',
time: '2026-03-01T17:30:00Z',
read: true,
},
];
function Demo() {
const [items, setItems] = useState(SAMPLE);
const markRead = (id: string) =>
setItems((list) => list.map((i) => (i.id === id ? { ...i, read: true } : i)));
return (
<NotificationCenter
items={items}
onMarkRead={markRead}
onMarkAllRead={() => setItems((list) => list.map((i) => ({ ...i, read: true })))}
onSelect={() => {}}
hasMore
onLoadMore={() => {}}
footer={<Link href="#/components/notification-center">Notification settings</Link>}
now={NOW}
timeZone="UTC"
align="start"
/>
);
}
<Demo />Usage examples
Custom trigger
import { Button } from '@rookcom/monarch-react';
import { NotificationCenter, type NotificationItem } from '@rookcom/monarch-react/collaboration';
const NOW = '2026-03-02T12:00:00Z';
const SAMPLE: NotificationItem[] = [
{
id: 'n1',
title: 'Ana Lopez mentioned you in Launch checklist',
description: '“Can you confirm the legal sign-off date?”',
time: '2026-03-02T11:42:00Z',
actor: { name: 'Ana Lopez' },
},
{
id: 'n2',
title: 'Ben Okafor assigned you Update pricing page',
time: '2026-03-02T09:15:00Z',
actor: { name: 'Ben Okafor' },
},
{
id: 'n3',
title: 'Chloé Martin replied to your comment',
description: '“Agreed, let’s ship the shorter version.”',
time: '2026-03-02T08:03:00Z',
actor: { name: 'Chloé Martin' },
read: true,
},
{
id: 'n4',
title: 'Your weekly export is ready',
time: '2026-03-01T17:30:00Z',
read: true,
},
];
<NotificationCenter
items={SAMPLE}
trigger={<Button size="sm">Inbox · 2 unread</Button>}
now={NOW}
timeZone="UTC"
align="start"
/>Purpose
Gives every app the same in-app inbox: a bell in the header or rail that shows how many notifications are unread and opens a panel to read, open and clear them. The product supplies the items and handles reading and navigation.
Use when
- An app header or icon rail that needs mentions, assignments and replies.
- Any surface that marks notifications read when opened.
Avoid when
- A full-page notifications inbox with bulk actions: build it from ActivityTimeline or a list.
- Transient confirmations: use Toast.
- Per-type delivery settings: use SwitchSettingsList on a settings page and link to it in
footer.
Design principles
- Unread is never colour alone: bold text, a dot and a hidden “Unread:” prefix.
- The count on the bell is in its accessible name (“Notifications, 3 unread”), the visual badge is hidden.
- One panel, two tabs: All and Unread, through Monarch TabsFilter, rather than separate pages.
- Reading is the product’s decision:
markReadOnSelectis on by default, and both read actions are callbacks.
API reference
import { NotificationCenter } from '@rookcom/monarch-react/collaboration';| Export | Kind | Rendering | Root class |
|---|---|---|---|
NotificationCenter | component | client | .mn-notification-center |
The ref and button attributes go to the built-in bell. With trigger, pass your own element and give it the count in its name.
open / defaultOpen / onOpenChange and filter / defaultFilter / onFilterChange work controlled or uncontrolled.
Items are filtered client-side by read on the Unread tab; refetch in onFilterChange when you page on the server.
Generated from the TypeScript declarations of packages/react/src/components/collaboration/notification-center.
Properties
NotificationCenter
A bell with the unread count that opens a panel of notifications: All and Unread tabs, day groups, mark one or all as read, load more and an empty state. The product supplies the data and callbacks.
| Prop | Type | Description |
|---|---|---|
items required | readonly NotificationItem[] | Loaded notifications, newest first. |
align | "center" | "start" | "end" | Panel alignment against the trigger. Default `end`. |
children | ReactNode | |
className | string | |
defaultFilter | NotificationFilter | |
defaultOpen | boolean | |
disabled | boolean | |
empty | ReactNode | Replaces the built-in empty state. |
error | ReactNode | First-load failure, e.g. an InlineErrorRetry. Replaces the list. |
filter | NotificationFilter | The All / Unread tab. The list is filtered by `read`; fetch server-side too if you page. |
footer | ReactNode | Below the list: a link to all notifications or to preferences. |
groupByDay | boolean | Group items under Today, Yesterday and dates. Default `true`. |
hasMore | boolean | Another page exists. Shows LoadMore at the end of the list. |
id | string | |
loading | boolean | First load: shows a skeleton. |
loadingMore | boolean | |
loadMoreError | boolean | |
locale | string | |
markReadOnSelect | boolean | Call `onMarkRead` when an unread item is activated. Default `true`. |
messages | Partial<NotificationCenterMessages> | |
now | DateInput | Reference time for relative times and day groups. |
onFilterChange | ((filter: NotificationFilter) => void) | |
onLoadMore | (() => void) | |
onMarkAllRead | (() => void) | Marks everything read. Without it there is no "Mark all as read" button. |
onMarkRead | ((id: string) => void) | Marks one item read. Without it items have no "Mark as read" button. |
onOpenChange | ((open: boolean) => void) | |
onSelect | ((item: NotificationItem) => void) | An item was activated. Navigate here, or use `href`. |
open | boolean | |
panelClassName | string | Class for the panel. |
side | "top" | "right" | "bottom" | "left" | |
timeZone | string | |
trigger | ReactElement<unknown, string | import("/home/runner/work/monarch/monarch/node_modules/@types/react/index").JSXElementConstructor<any>> | A custom trigger element (a rail item, a menu row). Give it the unread count in its name. |
unreadCount | number | Unread total from the server, which may exceed the loaded items. Default: unread items in `items`. |
Variants
| Prop | Values | Description |
|---|---|---|
groupByDay | 'true''false' | Today, Yesterday and dated headings, or one flat list. |
trigger | 'bell (default)''custom element' | A rail item or menu row instead of the bell button. |
States
| State | Exposed as | Behaviour |
|---|---|---|
| Unread | data-unread | Bold title, accent dot, “Mark as read” button when onMarkRead is set. |
| Read | – | Muted text, no dot. |
| Loading | – | Skeleton rows. |
| Error | – | The error node replaces the list. |
| Empty | – | “You’re all caught up”, or “No unread notifications” on the Unread tab. |
| Open | data-state="open" | The bell stays highlighted while the panel is open. |
Interaction behaviour
- Clicking the bell opens the panel; clicking outside or Escape closes it and returns focus to the bell.
- Activating an item calls
onMarkRead(if unread andmarkReadOnSelect) andonSelect; withhrefit is a real link. - “Mark all as read” is disabled when nothing is unread.
- LoadMore at the end of the list calls
onLoadMore.
Keyboard support
| Keys | Action |
|---|---|
| Enter / Space | On the bell, opens the panel and moves focus into it. |
| Tab | Moves through Mark all as read, the tabs, each item and its Mark as read button. |
| Arrow Left / Right, Home / End | On the tabs, switch between All and Unread. |
| Escape | Closes the panel and returns focus to the bell. |
Accessibility
- Role
- button (bell) controlling a dialog (panel)
- Accessible name
- Bell: “Notifications, N unread”. Panel: labelled by its heading.
- The bell has
aria-haspopup="dialog"andaria-expanded. - Day headings label their lists, so a screen reader hears “Today, list, 2 items”.
- Unread items start with hidden “Unread:” text; the dot is decorative.
- The tabs are a real tablist with one panel; the panel is scrollable and focusable.
Responsive behaviour
- The panel is 24rem wide, never wider than the viewport minus 1rem, and at most 34rem tall; the list scrolls inside it.
- Day headings stick to the top while scrolling.
Theme support
- The panel is a Monarch popover surface. The badge uses
danger-solidwith a ring in the page surface colour; the unread dot usesaccent-solid. - Forced colours draw the badge and dot in CanvasText.
Do and don't
Do
- Write titles as sentences with the actor first: “Ana Lopez mentioned you in Launch checklist”.
- Pass
unreadCountfrom the server when you page. - Link to notification settings in
footer.
Don't
- Do not show the badge count anywhere without the bell’s accessible name.
- Do not mark items read just because the panel opened.
- Do not put actions other than read state inside the list rows.
Related components
- ActivityTimeline: Full-page feeds of events.
- Tabs: TabsFilter drives the All and Unread tabs.
- Popover: The panel surface.
- LoadMore: Paging older notifications.
- Toast: Transient confirmations.
Dependencies
| Kind | Depends on |
|---|---|
| npm packages | react |
| Monarch components | Popover, Tabs, Button, Avatar, RelativeTime, LoadMore, EmptyState, Skeleton |
Testing coverage
- Names the bell with the unread count; caps the badge at 99+; no badge when all are read.
- Opens a labelled dialog, moves focus in, and Escape returns focus to the bell.
- Groups by day and marks unread items for screen readers; href items are links.
- Selecting marks unread items read and calls onSelect; Mark as read and Mark all as read.
- All and Unread tabs by keyboard, uncontrolled and controlled; controlled open.
- Empty states for both tabs; load more; loading and error.
- Flat list, footer, custom trigger, translations, forwarded ref.
| Check | Covered |
|---|---|
| Axe in jsdom | Yes |
| Server rendering | Yes |
| Browser suites | Visual baselines: /__gallery/notification-center (closed and open); Cross-browser axe: /components/notification-center |
Tests: packages/react/src/components/collaboration/notification-center