Source: https://monarchui.dev/components/notification-center/

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.

@rookcom/monarch-react/collaboration

Component

## Bell and panel

## Usage examples

### Custom trigger

## 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: `markReadOnSelect` is on by default, and both read actions are callbacks.

## API reference

```tsx
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 and `markReadOnSelect`) and `onSelect`; with `href` it 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"` and `aria-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-solid` with a ring in the page surface colour; the unread dot uses `accent-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 `unreadCount` from 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](https://monarchui.dev/components/activity-timeline/): Full-page feeds of events.
- [Tabs](https://monarchui.dev/components/tabs/): TabsFilter drives the All and Unread tabs.
- [Popover](https://monarchui.dev/components/popover/): The panel surface.
- [LoadMore](https://monarchui.dev/components/load-more/): Paging older notifications.
- [Toast](https://monarchui.dev/components/toast/): Transient confirmations.

## Dependencies

| Kind | Depends on |
| --- | --- |
| npm packages | `react` |
| Monarch components | [Popover](https://monarchui.dev/components/popover/), [Tabs](https://monarchui.dev/components/tabs/), [Button](https://monarchui.dev/components/button/), [Avatar](https://monarchui.dev/components/avatar/), [RelativeTime](https://monarchui.dev/components/relative-time/), [LoadMore](https://monarchui.dev/components/load-more/), [EmptyState](https://monarchui.dev/components/empty-state/), [Skeleton](https://monarchui.dev/components/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`

## All example code

### Bell and panel

```tsx
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 />
```

### Custom trigger

```tsx
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"
/>
```
