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: —
const [items, setItems] = useState(seed);
<NotificationCenter
items={items}
onRead={(id) => markRead(id)}
onReadAll={() => markAllRead()}
onDismiss={(id) => remove(id)}
footer={<Button variant="ghost" size="sm">View all</Button>}
/>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.
<NotificationCenter
items={[]}
emptyTitle="You are up to date"
emptyDescription="New notifications appear here."
footer={<Button size="sm" variant="ghost">Open the log</Button>}
/>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.
<NotificationCenter
items={items}
footer={<Button size="sm" variant="ghost">View all 240</Button>}
/>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.
<NotificationCenter
items={[{ id: "report", title: "Report is ready", href: "/runs/128" }]}
/>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
<Inline justify="between">
<Text>Momentum overlay</Text>
<NotificationCenter items={items} label="Run notifications" />
</Inline>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.
| Export | Kind | Signature |
|---|---|---|
NotificationCenter | 10 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.
| Prop | Type | Default | Notes |
|---|---|---|---|
className? | string | undefined | — | |
emptyDescription? | string | undefined | — | |
emptyTitle? | string | undefined | — | |
footer? | ReactNode | — | |
items | readonly 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.
Related