Notification Center
Free6 premium variants: dropdown, drawer, inline, activity feed, compact and grouped — with mark-as-read, dismiss, search, tab filtering and inline actions.
import { SvNotificationCenterComponent } from '@styloviz/notification-center';Demo & usage
Basic usage
Usage example — open the gallery for the full interactive demo.
An interactive demo for this component lives in the gallery.
Open live demoMore examples
Dropdown with search & tabs
<sv-notification-center variant="dropdown" [notifications]="notifications" [showSearch]="true" [tabs]="['All', 'Unread']" (markAllRead)="markAll()" (dismiss)="dismiss($event)" />
Optimistic writes that can fail
<!-- pendingIds dims the row and stops it responding while a write is in flight. itemErrors is its counterpart: the row whose write FAILED says so, in place, with a Try again — otherwise a dismissal that was rolled back looks like a click that never landed, and the user clicks again against an endpoint that is refusing. --> <sv-notification-center variant="dropdown" [notifications]="notifications()" [pendingIds]="inFlight()" [itemErrors]="failures()" (dismiss)="onDismiss($event)" (retryItem)="onDismiss({ notification: $event })" />
Merging a realtime stream
// The panel takes [notifications] as an input, so merging is yours. // svMergeRecords answers the four questions a live list asks - // identity, version, deduplication and order - and reports what it // did, so a stream that keeps redelivering stale revisions is visible // rather than silent. import { svMergeRecords } from '@styloviz/core'; socket.on('notification', (incoming) => { const merge = svMergeRecords(this.items(), [incoming]); this.items.set(merge.items); // A lower version than the copy held: refused, and worth knowing. if (merge.rejected.length) console.warn('stale', merge.rejected); // Rows dropped because they share a dedupeKey. The headline is yours // to write - "and 2 more comments" - which is why no count is // written onto the survivor. if (merge.collapsed.length) this.noteCollapsed(merge.collapsed); });
A "View all" link to a full notifications page
<!-- Off by default: a control that promises a page and does nothing is worse than no control, because the user concludes the page is broken rather than absent. Opt in once you have somewhere to send them. --> <sv-notification-center variant="dropdown" [notifications]="notifications" [showViewAll]="true" (viewAll)="router.navigateByUrl('/notifications')" />
When to use
- Aggregating app notifications in a bell dropdown, drawer or inline feed.
- You need unread counts, mark-as-read, search and tab filtering.
- For one-off transient messages, use Toast instead.
Async states
- loading
- error
- retry
- partial
Item identity & owned state
- Restorable state
- pendingIds
Bind these to put a saved view back — the open sections, the hidden columns, the selected rows — and read their
Changeevents to store what the user does.
Inputs
| Name | Type | Default | Description |
|---|---|---|---|
| variant | NotificationCenterVariant | 'dropdown' | Presentation: dropdown, drawer, inline, feed, compact or grouped. |
| notifications | NotificationItem[] | [] | The notifications to display. |
| maxHeight | string | '400px' | Max height of the scrollable list (CSS length). |
| tabs | string[] | ['All', 'Unread', 'Mentions', 'System'] | Filter tab labels. |
| panelTitle | string | 'Notifications' | Panel heading text. |
| showSearch | boolean | true | Show the search box. |
| animateBell | boolean | true | Animate the bell icon on new notifications. |
| showViewAll | boolean | false | Render the panel's "View all" control. Off by default, and that default is the fix. The control used to be unconditional markup with no handler on it — a link to the notifications page that went nowhere, which is worse than no link because a user who presses it concludes the page is broken rather than absent. A host that has somewhere to send them opts in and handles `viewAll`; a host that does not never shows the control. |
| loading | boolean | false | True while notifications are being fetched. Shows a placeholder in place of the list when there is nothing to show yet. |
| error | SvDataError | string | null | null | Load failure. Use `toSvDataError()` from `@styloviz/core` to normalize an `HttpErrorResponse`. |
| unreadTotal | number | null | null | The server's unread total for the WHOLE feed. `null` counts the rows in hand instead. |
| redactedLabel | string | 'This notification is no longer availab… | What a redacted row shows instead of its title. |
| hasMore | boolean | false | More notifications exist on the server. Renders the "Load more" control. A notification feed is unbounded — without this a host has to send the user's entire history to render a dropdown. |
| loadingMore | boolean | false | True while the next page is being fetched. |
| pendingIds | readonly string[] | ReadonlySet<string> | [] | Ids with a write in flight. Those rows dim and stop responding, so a second click cannot race the first and leave `rollback()` ambiguous. |
| itemErrors | Readonly<Record<string, string>> | ReadonlyMap<string, string> | {} | Ids whose last write FAILED, mapped to the message to show on that row. `pendingIds` HAD NO COUNTERPART, AND THAT IS Optimistic read/dismiss rolls back correctly — the row reverts, and the user is told nothing. A notification that silently un-dismissed itself looks like the click never landed, so they click again, and again, against an endpoint that is refusing. PER ROW, not one banner. `error` above is for the FEED — "could not load notifications" — and putting a single row's failure there says the whole list is broken. The two are different facts and the audit's own reasoning for `role="alert"` vs `role="status"` applies: scope the message to what actually failed. ```ts this.itemErrors.set({ 'n-14': 'Could not mark as read.' }); ``` |
| debounceMs | number | 300 | Delay before `queryChange` fires, in milliseconds. `0` emits immediately. |
| serverSide | boolean | false | Filter and search on the server instead of in the component. Leaves the supplied `notifications` untouched — the host is responsible for returning only matching rows. Required for a real feed: filtering client-side can only ever search the page you already downloaded. |
| open | boolean (two-way) | false | Whether the panel is open. Two-way bindable, so a host can open the centre from a route or a keyboard shortcut. Ignored by the always-visible `inline` and `feed` variants. |
| activeTab | string (two-way) | 'All' | The selected tab label. Two-way bindable, so the tab survives a reload. |
| search | string (two-way) | '' | The search box contents. Two-way bindable. |
Outputs
| Event | Payload type | Description |
|---|---|---|
| notificationClick | NotificationClickEvent | Emitted when a notification row is clicked. |
| actionClick | NotificationActionClickEvent | Emitted when an inline action on a notification is clicked. |
| markAllRead | NotificationMarkAllReadEvent | Emitted when "mark all as read" is triggered. |
| dismiss | NotificationDismissEvent | Emitted when a notification is dismissed. |
| queryChange | NotificationQuery | The tab or search term changed — fetch the matching notifications. Debounced by `debounceMs`, and never emitted twice for the same query. |
| loadMore | NotificationLoadMoreEvent | The user asked for the next page. |
| retry | void | The host should re-run the failed load. |
| viewAll | void | The user asked for the full notifications surface — navigate there. Only reachable when `showViewAll` is set; the panel closes first, because leaving a floating panel open over the page it just navigated to is how a dropdown ends up covering its own destination. |
| retryItem | NotificationItem | The row's "Try again" was pressed. |
| openChange | boolean | Emits the new `open` whenever it changes — the output half of `[(open)]`. |
| activeTabChange | string | Emits the new `activeTab` whenever it changes — the output half of `[(activeTab)]`. |
| searchChange | string | Emits the new `search` whenever it changes — the output half of `[(search)]`. |
<sv-notification-center-pro>
Also in this family — SvNotificationCenterProComponent from @styloviz-pro/notification-center.
Async states
- loading
- error
- retry
- partial
Item identity & owned state
- Restorable state
- pendingIds
Bind these to put a saved view back — the open sections, the hidden columns, the selected rows — and read their
Changeevents to store what the user does.
Inputs
| Name | Type | Default | Description |
|---|---|---|---|
| variant | NotificationCenterVariant | 'dropdown' | Presentation: bell with `dropdown`, `compact`, `grouped` (date sections) or `drawer` panel, or an always-visible `inline` panel or activity `feed`. |
| notifications | NotificationItem[] | [] | The notifications to show. The component deduplicates, orders and filters them, and overlays optimistic read/dismiss writes until the host confirms them. |
| maxHeight | string | '400px' | Maximum height of the scrollable list, as a CSS length. Not used by the `drawer`, which fills the viewport height. |
| tabs | string[] | ['All', 'Unread', 'Mentions', 'System'] | Filter tab labels, shown in the `dropdown`, `drawer` and `inline` variants. `Unread`, `Mentions` and `System` filter client-side; any other label filters nothing, and with `serverSide` the host does the filtering. |
| panelTitle | string | 'Notifications' | Heading of the panel, also the drawer dialog's accessible name. |
| showSearch | boolean | true | Show the search box in the `dropdown`, `drawer`, `grouped` and `inline` variants. |
| animateBell | boolean | true | Bounce the bell icon while there are unread notifications. |
| showViewAll | boolean | false | Render the panel's "View all" control. Off by default, and that default is the fix. The control used to be unconditional markup with no handler on it, in three variants — a link to the notifications page that went nowhere, which is worse than no link because a user who presses it concludes the page is broken rather than absent. A host that has somewhere to send them opts in and handles `viewAll`; a host that does not never shows the control. |
| loading | boolean | false | True while notifications are being fetched. Shows a placeholder in place of the list when there is nothing to show yet. |
| error | SvDataError | string | null | null | Load failure. Use `toSvDataError()` from `@styloviz/core` to normalize an `HttpErrorResponse`. |
| unreadTotal | number | null | null | The server's unread total for the WHOLE feed. `null` counts the rows in hand instead. |
| redactedLabel | string | 'This notification is no longer availab… | What a redacted row shows instead of its title. |
| hasMore | boolean | false | More notifications exist on the server. Renders the "Load more" control. A notification feed is unbounded — without this a host has to send the user's entire history to render a dropdown. |
| loadingMore | boolean | false | True while the next page is being fetched. |
| pendingIds | readonly string[] | ReadonlySet<string> | [] | Ids with a write in flight. Those rows dim and stop responding, so a second click cannot race the first and leave `rollback()` ambiguous. |
| itemErrors | Readonly<Record<string, string>> | ReadonlyMap<string, string> | {} | Ids whose last write FAILED, mapped to the message to show on that row. `pendingIds` HAD NO COUNTERPART, AND THAT IS Optimistic read/dismiss rolls back correctly — the row reverts, and the user is told nothing. A notification that silently un-dismissed itself looks like the click never landed, so they click again, and again, against an endpoint that is refusing. PER ROW, not one banner. `error` above is for the FEED — "could not load notifications" — and putting a single row's failure there says the whole list is broken. The two are different facts and the audit's own reasoning for `role="alert"` vs `role="status"` applies: scope the message to what actually failed. ```ts this.itemErrors.set({ 'n-14': 'Could not mark as read.' }); ``` |
| debounceMs | number | 300 | Delay before `queryChange` fires, in milliseconds. `0` emits immediately. |
| serverSide | boolean | false | Filter and search on the server instead of in the component. Leaves the supplied `notifications` untouched — the host is responsible for returning only matching rows. Required for a real feed: filtering client-side can only ever search the page you already downloaded. |
| open | boolean (two-way) | false | Whether the panel is open. Two-way bindable, so a host can open the centre from a route or a keyboard shortcut. Ignored by the always-visible `inline` and `feed` variants. |
| activeTab | string (two-way) | 'All' | The selected tab label. Two-way bindable, so the tab survives a reload. |
| search | string (two-way) | '' | The search box contents. Two-way bindable. |
Outputs
| Event | Payload type | Description |
|---|---|---|
| notificationClick | NotificationClickEvent | A notification row was clicked; it (and any rows collapsed into it) is optimistically marked read. Carries the row and a `rollback()`. |
| actionClick | NotificationActionClickEvent | One of a notification's action buttons was clicked. Carries the notification and the action `id`; not emitted for redacted rows. |
| markAllRead | NotificationMarkAllReadEvent | "Mark all read" was clicked; every row is optimistically marked read. Carries the previously unread ids and a `rollback()`. |
| dismiss | NotificationDismissEvent | A notification was dismissed and optimistically removed from the list. Carries the row, any collapsed ids, and a `rollback()`. |
| queryChange | NotificationQuery | The tab or search term changed — fetch the matching notifications. Debounced by `debounceMs`, and never emitted twice for the same query. |
| loadMore | NotificationLoadMoreEvent | The user asked for the next page. |
| retry | void | The host should re-run the failed load. |
| viewAll | void | The user asked for the full notifications surface — navigate there. Only reachable when `showViewAll` is set; the panel closes first, because leaving a floating panel open over the page it just navigated to is how a dropdown ends up covering its own destination. |
| retryItem | NotificationItem | The row's "Try again" was pressed. |
| openChange | boolean | Emits the new `open` whenever it changes — the output half of `[(open)]`. |
| activeTabChange | string | Emits the new `activeTab` whenever it changes — the output half of `[(activeTab)]`. |
| searchChange | string | Emits the new `search` whenever it changes — the output half of `[(search)]`. |
Wire it to your API
This component is headless about transport: it renders what you give it and reports what the user asks for. Nothing below requires @styloviz/data — it just saves you writing the debounce, the request guard and the retry policy by hand.
Template
Bound to the names this component actually exposes.
<sv-notification-center [serverSide]="true" [notifications]="data.items()" [loading]="data.loading()" [error]="data.error()" [hasMore]="data.hasMore()" (queryChange)="data.setQuery($event)" (retry)="data.reload()" />
Host class
One controller handles debounce, request-guarding, cancellation and retry.
import { svDataController, createHttpDataSource } from '@styloviz/data'; readonly data = svDataController( createHttpDataSource<Row>({ url: '/api/rows' }), );
- `queryChange` carries an `SvDataQuery` — page, page size, sorts, filters, search and cursor in one object. One handler covers every way the user can change what they are asking for.
- `serverSide` switches **off** the built-in filtering, sorting and paging. Leave it false and the component works entirely from the array you pass — which is what you want for a few hundred rows.
- `debounceMs` (default 300) collapses a burst of typing into one request. Set it to 0 only when the query is driven by clicks.
- `hasMore` and `loadMore` drive incremental paging; bind `hasMore` from the controller's own signal of the same name.
- Normalize failures with `toSvDataError()` from `@styloviz/core`. It derives `retryable` from the status, so a 404 does not get a "Try again" button that can only fail again.
Full walkthrough: Connecting to a backend.
Customization & theming
- Dropdown, drawer, inline or feed presentations.
- Mark-as-read, dismiss and search.
- Priority indicators and inline actions.
- Opt-in "View all" footer via [showViewAll] + (viewAll).
- A realtime identity contract: `version` (a monotonic revision — a lower one is stale), `seq` (position in the stream, used for ordering only when every row has one) and `dedupeKey` (two rows that are one event). Merge your stream with `svMergeRecords` from `@styloviz/core`, which answers identity, staleness, deduplication and order in one pass and reports what it did. The panel applies the same rules to whatever is in its input, so a duplicate id renders one row instead of throwing.
- Per-row async state: `[pendingIds]` dims a row and stops it responding while its write is in flight, `[itemErrors]` (a record or a Map of id → message) renders that row's failure in place with a Try again wired to `(retryItem)`. Both work in all six variants.
Best practices
- Do: Decide which of two things this instance is: a durable inbox (dropdown/drawer/compact/grouped/inline — items the user acts on and clears) or an activity feed (`feed` — actor + verb + target, read-only history). They share one component and one item type, and the feed-only fields are optional on every row, so nothing stops you mixing them — but users read the two very differently.
- Don't: Don't use it for the other two things a notification system does. Transient alerts ("Saved", "Connection lost") are Toast: they expire, have no read state and are never fetched again. Action workflows (an approval queue, an escalation) are the host's records with their own states — inline `actions` are a shortcut into that workflow, not its store, so an Approve button must be re-validated by the server and the row updated from the result. The four differ in retention (seconds · until cleared · forever · until resolved), read semantics and delivery guarantee, and one inbox that holds all four gets all four wrong.
- Do: Surface an unread count on the trigger and keep it in sync.
- Do: Merge a realtime stream with `svMergeRecords` rather than appending to the array. Appending eventually delivers the same `id` twice, and the panel tracks rows by `id` — a duplicate track key is an exception, so the failure is the whole centre disappearing rather than a row showing twice. Give rows a `version` so a replayed older revision is refused instead of quietly reverting the row.
- Do: Bind `[pendingIds]` while a read/dismiss is in flight and `[itemErrors]` when one fails. The rollback on failure is already correct, but a row that silently reappears looks like the click never landed — so the user clicks again, against an endpoint that is refusing. Keep it per row: `[error]` is the feed's own state, and routing one row's failure there says the whole list is broken.
- Do: Group or tab notifications so high-priority items aren't buried.
- Tip: Provide inline actions (approve, view) so users act without leaving the panel.
- Don't: Don't set [showViewAll] until a notifications page exists — the control is off by default so the panel never ships a link that goes nowhere.
Accessibility
- Trigger exposes `aria-haspopup`/`aria-expanded`; the panel closes on Escape and outside click.
- Notification rows are keyboard-operable `role="button"` elements (Tab to reach, Enter/Space to open); inline actions and dismiss are labelled buttons.
- A pending row carries `aria-busy`, so the dim is not the only signal that the click landed. A failed write is a `role="alert"` scoped to its row — something the user asked for did not happen, and it happened because of their click.
Feedback on Notification Center
Spotted a gap in this page, or something that didn't work as described? Comments are GitHub Discussions in the public styloviz/community repo, so posting needs a GitHub account.