Skip to content

Feedback

NotificationCenter

An inbox for the notifications a user has already been shown, with read state.

Examples

The bell, the badge and the list

Open it and work through it: the count is the list's own state, marking a row read moves the badge, and dismissing removes it. The panel's open state stays local — a popover is not a route.

3 unread

Last action: —

The empty inbox

An inbox is empty the moment the last row is read or dismissed, and that is a state worth writing: the default copy says the reader is up to date, and `footer` is where the follow-up action goes.

Nothing expires here: the list is empty because every row was read, not because a timer ran out.

A long list scrolls, it does not grow

Eighteen rows, and the panel keeps its own height. The list is a scroll region, so it takes a tab stop — a keyboard user cannot drag a scrollbar.

The panel is capped at 70vh and the rows scroll inside it; the footer stays pinned below the list.

Rows that navigate

A row with an `href` renders as a real anchor, so middle-click and "open in a new tab" keep working. A row without one is a button, with the keyboard handler the pattern needs.

The second row is an anchor; the first is a button that reports the click to the caller.

In a topbar

Where it lives: one control in a row of controls, so the badge has to read at a glance and the panel has to align to the edge it hangs from.

Momentum overlay

Updated 2 minutes ago

Usage

import { NotificationCenter } from "@luc/ui";
<NotificationCenter
  items={items}
  onRead={(id) => markRead(id)}
  onReadAll={markAllRead}
  onDismiss={(id) => remove(id)}
/>

The list is fully controlled: the product owns persistence, the unread count and what "read" means. Only the panel's open state is local, because a popover is not a route.

API

Read from the published TypeScript API, so a prop that is not listed here does not exist and a default that is listed is the one the compiler sees.

One component, plus the item shape it renders (`title`, `description`, `timestamp`, `tone`, `read`, `href`). Timestamps are pre-formatted by the caller because a relative time is a product decision, not a library one.

Exported symbols
ExportKindSignature
NotificationCenter10 documented props({ items, onRead, onReadAll, onDismiss, onOpenItem, label, emptyTitle, emptyDescription, footer, className, }: NotificationCenterProps) => import("@types/react").JSX.Element

NotificationCenter props

Type: NotificationCenterProps

Values the compiler reads from the declaration.

NotificationCenter props
PropTypeDefaultNotes
className?string | undefined—
emptyDescription?string | undefined—
emptyTitle?string | undefined—
footer?ReactNode—
itemsreadonly NotificationItem[]—
label?string | undefined—
onDismiss?((id: string) => void) | undefined—
onOpenItem?((id: string) => void) | undefined—
onRead?((id: string) => void) | undefined—
onReadAll?(() => void) | undefined—

Guidelines

Do

  • Use it for everything that accumulated while the reader was elsewhere; a toast is for what they just caused.
  • Keep the panel's own open state local, and let the product own which rows are read.
  • Give the long list a `footer` — a "View all" link is what keeps the panel from becoming the archive.

Don't

  • Do not expire rows on a timer; an inbox that deletes itself is not an inbox.
  • Do not treat the unread count as an announcement — the badge is a count, not a live region.
  • Do not put the row's own actions inside its title; the row is one target with one meaning.

Accessibility

The trigger is a button whose name states the count (`Notifications, 3 unread`), the panel is a popover whose content is the list, and every row is a real button — or an anchor when it has an `href` — so Enter and Space work without a key handler of our own. Unread state is carried by weight and a dot rather than by colour alone, and the dismiss control is revealed on hover *and* on focus, so it is reachable without a pointer.

Luc UI v1.0NotificationCenterdark theme