Notification Center

Free

6 premium variants: dropdown, drawer, inline, activity feed, compact and grouped — with mark-as-read, dismiss, search, tab filtering and inline actions.

</>sv-notification-center notificationsfeedoverlay
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 demo

More examples

Dropdown with search & tabs

example.htmlAngular
<sv-notification-center
  variant="dropdown"
  [notifications]="notifications"
  [showSearch]="true"
  [tabs]="['All', 'Unread']"
  (markAllRead)="markAll()"
  (dismiss)="dismiss($event)" />

Optimistic writes that can fail

example.htmlAngular
<!-- 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

example.tsTypeScript
// 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

example.htmlAngular
<!-- 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 Change events to store what the user does.

Inputs

NameTypeDefaultDescription
variantNotificationCenterVariant'dropdown'Presentation: dropdown, drawer, inline, feed, compact or grouped.
notificationsNotificationItem[][]The notifications to display.
maxHeightstring'400px'Max height of the scrollable list (CSS length).
tabsstring[]['All', 'Unread', 'Mentions', 'System']Filter tab labels.
panelTitlestring'Notifications'Panel heading text.
showSearchbooleantrueShow the search box.
animateBellbooleantrueAnimate the bell icon on new notifications.
showViewAllbooleanfalseRender 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.
loadingbooleanfalseTrue while notifications are being fetched. Shows a placeholder in place of the list when there is nothing to show yet.
errorSvDataError | string | nullnullLoad failure. Use `toSvDataError()` from `@styloviz/core` to normalize an `HttpErrorResponse`.
unreadTotalnumber | nullnullThe server's unread total for the WHOLE feed. `null` counts the rows in hand instead.
redactedLabelstring'This notification is no longer availab…What a redacted row shows instead of its title.
hasMorebooleanfalseMore 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.
loadingMorebooleanfalseTrue while the next page is being fetched.
pendingIdsreadonly 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.
itemErrorsReadonly<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.' }); ```
debounceMsnumber300Delay before `queryChange` fires, in milliseconds. `0` emits immediately.
serverSidebooleanfalseFilter 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.
openboolean (two-way)falseWhether 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.
activeTabstring (two-way)'All'The selected tab label. Two-way bindable, so the tab survives a reload.
searchstring (two-way)''The search box contents. Two-way bindable.

Outputs

EventPayload typeDescription
notificationClickNotificationClickEventEmitted when a notification row is clicked.
actionClickNotificationActionClickEventEmitted when an inline action on a notification is clicked.
markAllReadNotificationMarkAllReadEventEmitted when "mark all as read" is triggered.
dismissNotificationDismissEventEmitted when a notification is dismissed.
queryChangeNotificationQueryThe tab or search term changed — fetch the matching notifications. Debounced by `debounceMs`, and never emitted twice for the same query.
loadMoreNotificationLoadMoreEventThe user asked for the next page.
retryvoidThe host should re-run the failed load.
viewAllvoidThe 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.
retryItemNotificationItemThe row's "Try again" was pressed.
openChangebooleanEmits the new `open` whenever it changes — the output half of `[(open)]`.
activeTabChangestringEmits the new `activeTab` whenever it changes — the output half of `[(activeTab)]`.
searchChangestringEmits 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 Change events to store what the user does.

Inputs

NameTypeDefaultDescription
variantNotificationCenterVariant'dropdown'Presentation: bell with `dropdown`, `compact`, `grouped` (date sections) or `drawer` panel, or an always-visible `inline` panel or activity `feed`.
notificationsNotificationItem[][]The notifications to show. The component deduplicates, orders and filters them, and overlays optimistic read/dismiss writes until the host confirms them.
maxHeightstring'400px'Maximum height of the scrollable list, as a CSS length. Not used by the `drawer`, which fills the viewport height.
tabsstring[]['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.
panelTitlestring'Notifications'Heading of the panel, also the drawer dialog's accessible name.
showSearchbooleantrueShow the search box in the `dropdown`, `drawer`, `grouped` and `inline` variants.
animateBellbooleantrueBounce the bell icon while there are unread notifications.
showViewAllbooleanfalseRender 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.
loadingbooleanfalseTrue while notifications are being fetched. Shows a placeholder in place of the list when there is nothing to show yet.
errorSvDataError | string | nullnullLoad failure. Use `toSvDataError()` from `@styloviz/core` to normalize an `HttpErrorResponse`.
unreadTotalnumber | nullnullThe server's unread total for the WHOLE feed. `null` counts the rows in hand instead.
redactedLabelstring'This notification is no longer availab…What a redacted row shows instead of its title.
hasMorebooleanfalseMore 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.
loadingMorebooleanfalseTrue while the next page is being fetched.
pendingIdsreadonly 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.
itemErrorsReadonly<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.' }); ```
debounceMsnumber300Delay before `queryChange` fires, in milliseconds. `0` emits immediately.
serverSidebooleanfalseFilter 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.
openboolean (two-way)falseWhether 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.
activeTabstring (two-way)'All'The selected tab label. Two-way bindable, so the tab survives a reload.
searchstring (two-way)''The search box contents. Two-way bindable.

Outputs

EventPayload typeDescription
notificationClickNotificationClickEventA notification row was clicked; it (and any rows collapsed into it) is optimistically marked read. Carries the row and a `rollback()`.
actionClickNotificationActionClickEventOne of a notification's action buttons was clicked. Carries the notification and the action `id`; not emitted for redacted rows.
markAllReadNotificationMarkAllReadEvent"Mark all read" was clicked; every row is optimistically marked read. Carries the previously unread ids and a `rollback()`.
dismissNotificationDismissEventA notification was dismissed and optimistically removed from the list. Carries the row, any collapsed ids, and a `rollback()`.
queryChangeNotificationQueryThe tab or search term changed — fetch the matching notifications. Debounced by `debounceMs`, and never emitted twice for the same query.
loadMoreNotificationLoadMoreEventThe user asked for the next page.
retryvoidThe host should re-run the failed load.
viewAllvoidThe 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.
retryItemNotificationItemThe row's "Try again" was pressed.
openChangebooleanEmits the new `open` whenever it changes — the output half of `[(open)]`.
activeTabChangestringEmits the new `activeTab` whenever it changes — the output half of `[(activeTab)]`.
searchChangestringEmits 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.

usage.htmlAngular
<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.

host.component.tsTypeScript
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.

Loads giscus.app and GitHub — nothing is fetched until you click.