Skip to content
ShineUI
Install

NotificationCenter

The persistent inbox.

<sui-notification-center>CompositionsStable

About

The persistent inbox: a bell wearing the unread count and a floating panel of notifications with All and Unread filters, Mark all read, dismissible rows and a View all footer. Toast stays transient; this one remembers. Reach for it in the app header wherever activity should wait for the user.

Built from

Usage

<sui-notification-center align="start" width="360"></sui-notification-center> <script type="module"> import { defineCustomElements } from "@shineui/core/loader"; import "@shineui/core/shineui.css"; defineCustomElements(); const el = document.querySelector("sui-notification-center"); el.notifications = [ { id: "paid-1043", title: "Payment received, $6,420.00", detail: "Silver Hills Church paid INV-1043.", time: "11:02 AM", severity: "positive" }, { id: "overdue", title: "2 invoices are overdue", detail: "Riverbend Dental Group and Kye's Catering.", time: "9:15 AM", severity: "critical" }, { id: "close", title: "August close is ready for review", detail: "Two accounts still need a reconciliation.", time: "8:40 AM", severity: "attention" }, ]; /* SuiNotificationItem[] */ el.addEventListener("suiItemPick", (e) => console.log(e.detail)); </script>
import { SuiNotificationCenter } from "@shineui/react"; import "@shineui/core/shineui.css"; export function Example() { const notifications = [ { id: "paid-1043", title: "Payment received, $6,420.00", detail: "Silver Hills Church paid INV-1043.", time: "11:02 AM", severity: "positive" }, { id: "overdue", title: "2 invoices are overdue", detail: "Riverbend Dental Group and Kye's Catering.", time: "9:15 AM", severity: "critical" }, { id: "close", title: "August close is ready for review", detail: "Two accounts still need a reconciliation.", time: "8:40 AM", severity: "attention" }, ]; /* SuiNotificationItem[] */ return <SuiNotificationCenter align="start" width="360" notifications={notifications} onSuiItemPick={(e) => console.log(e.detail)} />; }
<script setup> import { SuiNotificationCenter } from "@shineui/vue"; import "@shineui/core/shineui.css"; const notifications = [ { id: "paid-1043", title: "Payment received, $6,420.00", detail: "Silver Hills Church paid INV-1043.", time: "11:02 AM", severity: "positive" }, { id: "overdue", title: "2 invoices are overdue", detail: "Riverbend Dental Group and Kye's Catering.", time: "9:15 AM", severity: "critical" }, { id: "close", title: "August close is ready for review", detail: "Two accounts still need a reconciliation.", time: "8:40 AM", severity: "attention" }, ]; // SuiNotificationItem[] const onChange = (e) => console.log(e.detail); </script> <template> <SuiNotificationCenter align="start" width="360" :notifications="notifications" @sui-item-pick="onChange" /> </template>
import { Component } from "@angular/core"; import { SuiNotificationCenter } from "@shineui/angular"; @Component({ standalone: true, imports: [SuiNotificationCenter], template: `<sui-notification-center align="start" width="360" [(open)]="open" [notifications]="notifications" (suiItemPick)="onChange($event)"></sui-notification-center>`, }) export class Example { open = false; notifications = [ { id: "paid-1043", title: "Payment received, $6,420.00", detail: "Silver Hills Church paid INV-1043.", time: "11:02 AM", severity: "positive" }, { id: "overdue", title: "2 invoices are overdue", detail: "Riverbend Dental Group and Kye's Catering.", time: "9:15 AM", severity: "critical" }, { id: "close", title: "August close is ready for review", detail: "Two accounts still need a reconciliation.", time: "8:40 AM", severity: "attention" }, ]; // SuiNotificationItem[] onChange(e: CustomEvent) { console.log(e.detail); } }

Basic

The bell wears the unread count in an overlay badge and is named "Notifications, 3 unread", with aria-expanded and aria-haspopup="dialog". Each unread row also reads "Unread" to a screen reader.

<div style="padding:6px"> <sui-notification-center id="nc-basic" align="start" width="360"></sui-notification-center> </div>
// setup(root): properties and events setup(root) { root.querySelector('#nc-basic').notifications = inbox().slice(0, 3); }

Open panel

Unread rows sit on the sunken surface with the orange dot. All and Unread filter the list, Mark all read clears the badge, and hover or keyboard focus reveals the dismiss control on a row.

<div style="padding:6px;min-height:460px"> <sui-notification-center id="nc-panel" open align="start" width="360"></sui-notification-center> </div>
// setup(root): properties and events setup(root) { root.querySelector('#nc-panel').notifications = inbox(); }

Caught up

Open the bell: an empty list draws the caught-up state, and the bell wears no badge. Dismissing every row lands in the same place.

<div style="padding:6px;min-height:320px"> <sui-notification-center id="nc-empty" align="start" width="336"></sui-notification-center> </div>
// setup(root): properties and events setup(root) { root.querySelector('#nc-empty').notifications = []; }

Events

suiItemPick is the navigate signal and fires on read and unread rows alike; suiRead only reports the state change. suiClose says why the panel closed.

Idle.
<div style="display:flex;gap:14px;align-items:flex-start;padding:6px;min-height:460px"> <sui-notification-center id="nc-events" align="start" width="360"></sui-notification-center> <span id="nc-log" style="font:460 13px/1.6 var(--font-sans);color:var(--ink-muted)">Idle.</span> </div>
// setup(root): properties and events setup(root) { const el = root.querySelector('#nc-events'); const log = root.querySelector('#nc-log'); el.notifications = inbox(); el.addEventListener('suiOpen', () => { log.textContent = 'Panel open.'; }); el.addEventListener('suiClose', (e) => { log.textContent = `Panel closed (${e.detail}).`; }); el.addEventListener('suiItemPick', (e) => { log.textContent = `Opened ${e.detail.title}`; }); el.addEventListener('suiRead', (e) => { log.textContent = `Marked read: ${e.detail.title}`; }); el.addEventListener('suiRemove', (e) => { log.textContent = `Dismissed ${e.detail.title}`; }); el.addEventListener('suiViewAll', () => { log.textContent = 'View all notifications.'; }); }

Keeping the panel open

suiClose is cancelable. This one vetoes the outside-pointer close, so a stray click on the page leaves the panel up and only the bell or Escape closes it.

Open the bell, then click the page.
<div style="display:flex;gap:14px;align-items:flex-start;padding:6px;min-height:460px"> <sui-notification-center id="nc-veto" align="start" width="360"></sui-notification-center> <span id="nc-veto-log" style="font:460 13px/1.6 var(--font-sans);color:var(--ink-muted)">Open the bell, then click the page.</span> </div>
// setup(root): properties and events setup(root) { const el = root.querySelector('#nc-veto'); const log = root.querySelector('#nc-veto-log'); el.notifications = inbox(); el.addEventListener('suiClose', (e) => { if (e.detail === 'outside') { e.preventDefault(); log.textContent = 'Outside click ignored. The panel stays open.'; return; } log.textContent = `Closed (${e.detail}).`; }); }

Data contract

Items are { id, title, detail, time, severity, read }. Activating a row emits suiItemPick and marks it read, which emits suiRead once; hover reveals dismiss, which emits suiRemove. Listen to suiViewAll to route to your activity page.

Keyboard

KeyAction
Enter SpaceOn the bell, opens the panel and moves focus into it, or closes it. On a row, activates it; on its dismiss control, removes the row and moves focus to the same control on the row that takes its place, or to the bell once the list is empty.
TabMoves through the panel: Mark all read, the two filters, each row and its dismiss control, then View all. Past View all it closes the panel with reason outside and carries on to the control after the bell; Shift+Tab before Mark all read closes it and lands on the bell.
EscapeCloses the panel and returns focus to the bell. Inside a dialog the panel takes the key first and the dialog stays.

API

<sui-notification-center>

Properties

PropertyAttributeTypeDefaultDescription
alignalign"end" | "start"'end'Which edge of the bell the panel lines up with, in reading order: end (the default) is the bell's right edge on a left-to-right page and its left edge under dir="rtl", so a bell at the end of a top bar opens its panel inward either way. The panel is fixed 8px under the bell and flips above when the viewport has no room.
listMaxHeightlist-max-heightnumber300Scrollable list height cap in px.
notificationsproperty onlySuiNotificationItem[][]Inbox rows. Property, not attribute. Replacing the array resets read and dismissed state. Unread rows sit on the sunken surface with the orange dot; hover or keyboard focus reveals the dismiss control.
openopenbooleanfalseShows the panel (the reference defaultOpen). Mutable; the element opens and closes itself, so plain HTML works without listeners. Opening by interaction moves focus into the panel; an outside pointer closes it without moving focus.
widthwidthnumber336Panel width in px.

Events

EventDetailReactVueDescription
suiCloseSuiCloseReason"cancel" | "close-button" | "escape" | "outside" | "pick" | "scrim" | "trigger"onSuiClose@sui-closeFires before the panel closes itself; detail is 'trigger' | 'outside' | 'escape'. Cancelable: preventDefault() keeps it open. It does not bubble.
suiItemPickSuiNotificationItemonSuiItemPick@sui-item-pickFires every time a row is activated, whatever its read state; detail is the item as the user saw it (so an unread row still reads read: false). This is the navigate signal, and activating also marks the row read. suiRead only reports that state change, and says nothing on an already-read row.
suiOpenvoidonSuiOpen@sui-openFires when an interaction opens the panel. It does not bubble.
suiReadSuiNotificationItemonSuiRead@sui-readFires when a row becomes read (row activation or "Mark all read"); detail is the item.
suiRemoveSuiNotificationItemonSuiRemove@sui-removeFires when a row's remove control is used; detail is the removed item. Focus moves to the same control on the row that takes its place, so Enter clears an inbox row by row, and to the bell once the list is empty. Named suiRemove because that is what the library calls this everywhere else. Dismissal has two shapes here and only two: suiClose carries a SuiCloseReason and says why something shut, and suiRemove carries the thing that went. This used to be suiDismiss, which was the only third name in the library, on an event that takes a row out of a list and hands you the row: the suiRemove shape exactly, as on sui-file-upload.
suiViewAllvoidonSuiViewAll@sui-view-allFires when "View all notifications" is activated. No detail.

Methods

MethodSignatureDescription
setFocus()setFocus() => Promise<void>Moves focus to the bell, the element's tab stop; it does not open the panel. The host's own focus() does the same.

Types

SuiCloseReason typeWhy a dismissible surface is closing. Detail of the cancelable suiClose. A consumer expresses "a stray backdrop click must not close this" by calling preventDefault() on the reason it does not accept, which is why none of these needs a prop of its own.
No members.
SuiKey typeA record's or an item's key wherever the library identifies one: a row, a tree node, a list item, a selected value. Numbers stay numbers, so a numeric id round-trips through selection, the events and a framework binding without turning into a string.
No members.
SuiNotificationItem interfaceOne inbox row. Severities follow the toast system; read rows lose the sunken ground and dot.
id?SuiKeyStable identity; defaults to the array index. A number stays a number in every event.
titlestringSentence-case, single-sentence headline.
detail?stringSupporting line under the title.
time?stringRelative or clock time, e.g. "11:02 AM" or "Yesterday".
severity?SuiNotificationSeveritypositive navy, attention soft orange, critical solid orange, info slate (default).
read?booleanAlready read.
SuiNotificationSeverity type
No members.

Theming

Every design token this element can render with, read from its own stylesheets and the ones it imports. Set a token on :root or on any ancestor and every use below follows it. The values shown are the ones in the theme you are viewing. The list is read per stylesheet, so an element that imports a shared drawing module lists that module's tokens too, whether or not it paints with all of them.

Color29

--accentthe one chart highlight: the flagged series, down candles, the peak cell--accent-markthe accent drawn as a mark on a page: active bars, sort arrows, the logotype--bordercard + table outer borders--border-strongcontrol borders, emphasized rules--brand--brand-contrasttext and glyphs sitting on solid brand fills--brand-softnavy 10% on white, quiet chips, selected rows--brand-soft-ink--danger--danger-soft--disabled-ink--focus-ring--hairlinerow separators--ink--ink-faintmetadata beside body ink: 4.8:1 on the page grounds (5.4:1 in dark)--ink-mutedsecondary text: 7:1 on the page grounds and 1.5 times faint--link--status-attention-bg--status-attention-inkOpen, Pending, In transit--status-neutral-bg--status-neutral-ink--status-positive-bg--status-positive-inkPaid, Cleared, Active--surfacecards, tables, inputs--surface-hovera hovered row or quiet control on any ground: the ink, translucent--surface-pressed--surface-raisedmenus, popovers, toasts: the card in light, a step above it in dark--surface-raised-hovera hovered or unread row inside a raised panel: the ink washed over it--surface-sunkentable headers, wells

Typography1

Ag--font-sans

Spacing and size1

800--z-overlay

Radius4

--radius-lgcards, tables, panels, dialogs--radius-mdALL controls: inputs, buttons, selects--radius-pillstatus pills, tabs-as-pills, company chip--radius-xscheckboxes, mini swatches

Elevation2

--focus-shadow--shadow-popdropdowns, popovers, toasts

Motion3

--duration-fasthovers, presses, every exit--duration-overlayevery entrance: palette, popover, dialog, drawer, toast, menu, select list--ease-out

Every name above is listed with its value and its role in Foundations.

See also