Skip to content
omaris

Communication

Notification Center

The bell in the top bar, and everything behind it.

import { NotificationCenter } from 'omaris'
Learn

Examples

Basic

The component never changes the list itself: onread, onreadall and ondismiss are requests, and the list you hand back is the answer. Hover a row for its ×; on a phone, swipe it.

Acme

<script lang="ts">	import { NotificationCenter, Text, type NotificationItem } from 'omaris';​	const now = Date.now();	const minutes = (m: number) => now - m * 60_000;​	let notifications = $state<NotificationItem[]>([		{			id: '1',			title: 'Amina Yusuf mentioned you in Checkout redesign',			body: 'Can you look at the empty cart state before Friday?',			time: minutes(2),			avatar: { name: 'Amina Yusuf' }		},		{			id: '2',			title: 'Deploy to production finished',			body: 'storefront@4f2c1a is live in every region.',			time: minutes(38),			icon: rocket		},		{			id: '3',			title: 'Dilan Karim assigned you Fix invoice rounding',			time: minutes(60 * 5),			avatar: { name: 'Dilan Karim' }		},		{			id: '4',			title: 'Hevi Salih approved Release 2.4',			time: minutes(60 * 26),			read: true,			avatar: { name: 'Hevi Salih' }		},		{			id: '5',			title: 'Weekly report is ready',			body: 'Orders up 12% on last week.',			time: minutes(60 * 24 * 9),			read: true,			icon: chart		}	]);</script>​{#snippet rocket()}	...{/snippet}{#snippet chart()}	...{/snippet}​<div	class="flex w-full items-center justify-between rounded-shape-md border border-border bg-surface-container-low px-4 py-2">	<Text variant="title-md">Acme</Text>	<NotificationCenter		{notifications}		onread={(id) =>			(notifications = notifications.map((n) => (n.id === id ? { ...n, read: true } : n)))}		onreadall={() => (notifications = notifications.map((n) => ({ ...n, read: true })))}		ondismiss={(id) => (notifications = notifications.filter((n) => n.id !== id))}	/></div>

Long backlog

A thousand of them. Past virtualize rows (50 by default) the list is a VirtualList, so the panel opens as fast with a backlog as without, and the Unread tab counts on the bell and in the tab alike.

Orders

<script lang="ts">	import { NotificationCenter, Text, type NotificationItem } from 'omaris';​	const PEOPLE = ['Amina Yusuf', 'Dilan Karim', 'Hevi Salih', 'Omar Nasir', 'Sara Kamal'];	const WHAT = ['commented on', 'reacted to', 'shared', 'edited', 'moved'];	const now = Date.now();​	let notifications = $state<NotificationItem[]>(		Array.from({ length: 1000 }, (_, i) => ({			id: String(i),			title: `${PEOPLE[i % 5]} ${WHAT[(i * 3) % 5]} Order ${4200 - i}`,			time: now - i * 47 * 60_000,			read: i > 12 && i % 7 !== 0,			avatar: { name: PEOPLE[i % 5] }		}))	);</script>​<div	class="flex w-full items-center justify-between rounded-shape-md border border-border bg-surface-container-low px-4 py-2">	<Text variant="title-md">Orders</Text>	<NotificationCenter		{notifications}		onread={(id) =>			(notifications = notifications.map((n) => (n.id === id ? { ...n, read: true } : n)))}		onreadall={() => (notifications = notifications.map((n) => ({ ...n, read: true })))}		ondismiss={(id) => (notifications = notifications.filter((n) => n.id !== id))}	/></div>

Full screen

Below mobileQuery the panel is a screen of its own: a back arrow, the title and "Mark all read" in a top app bar, and the list below. It is forced here with a query that always matches, so it shows on a desktop too: swipe a row left to dismiss it, right to mark it read.

<script lang="ts">	import { NotificationCenter, type NotificationItem } from 'omaris';​	const now = Date.now();​	let notifications = $state<NotificationItem[]>([		{			id: 'a',			title: 'Your order from Zagros Café is on its way',			body: 'Arriving in about 20 minutes.',			time: now - 4 * 60_000,			avatar: { name: 'Zagros Café' }		},		{			id: 'b',			title: 'Sara Kamal sent you 25,000 IQD',			time: now - 3 * 3600_000,			avatar: { name: 'Sara Kamal' }		},		{			id: 'c',			title: 'Rate your last ride',			body: 'Tell us how it went with Omar.',			time: now - 30 * 3600_000,			read: true,			avatar: { name: 'Omar Nasir' }		}	]);</script>​<NotificationCenter	{notifications}	mobileQuery="(min-width: 0px)"	onread={(id) =>		(notifications = notifications.map((n) => (n.id === id ? { ...n, read: true } : n)))}	onreadall={() => (notifications = notifications.map((n) => ({ ...n, read: true })))}	ondismiss={(id) => (notifications = notifications.filter((n) => n.id !== id))}/>

Overridden

classes reaches the bell, the badge, the panel and every part of a row; icon replaces the bell, footer adds a way out, empty replaces both empty states, and every label can be translated.

CI

<script lang="ts">	import { Button, NotificationCenter, Text, type NotificationItem } from 'omaris';​	const now = Date.now();​	let notifications = $state<NotificationItem[]>([		{ id: 'x', title: 'Build 812 failed on main', time: now - 90_000 },		{ id: 'y', title: 'Build 811 passed', time: now - 40 * 60_000, read: true }	]);</script>​<div	class="flex w-full items-center justify-between rounded-shape-md border border-border bg-surface-container-low px-4 py-2">	<Text variant="title-md">CI</Text>	<NotificationCenter		{notifications}		title="Builds"		label="Build alerts"		markAllLabel="Acknowledge all"		unreadLabel="New"		tab="unread"		onread={(id) =>			(notifications = notifications.map((n) => (n.id === id ? { ...n, read: true } : n)))}		onreadall={() => (notifications = notifications.map((n) => ({ ...n, read: true })))}		classes={{			bell: 'text-destructive',			panel: 'w-80',			row: 'data-unread:bg-destructive-container/40',			dot: 'bg-destructive',			title: 'font-mono'		}}	>		{#snippet icon()}			...		{/snippet}		{#snippet empty(tab)}			<Text variant="body-md" tone="muted" align="center">				{tab === 'unread' ? 'Every build acknowledged.' : 'No builds yet.'}			</Text>		{/snippet}		{#snippet footer({ close })}			<Button variant="text" size="xs" onclick={close}>Close</Button>		{/snippet}	</NotificationCenter></div>

When to use it

Use it for

  • The bell in an app's top bar: a count of what is unread, and a panel of the latest activity behind it — mentions, assignments, approvals, finished jobs.
  • An inbox people triage in place: open one to mark it read, "Mark all read" for the rest, swipe or × to dismiss.
  • A long backlog. Past virtualize rows the list is windowed, so a thousand notifications open as fast as ten.
  • The same feature on a phone and a desktop: a popover under the bell above mobileQuery, a full-screen view with a back arrow below it.

Not for

  • Telling someone that what they just did finished → Toast. The center is the history; a toast is the moment.
  • A problem with the page they are on → Alert, inline where it applies.
  • A full-page inbox with filters, search and bulk selection → List or Table on a route of its own; link to it from footer.
  • A count on a navigation destination → Badge on that destination.

Do

  • Own the list. onread, onreadall and ondismiss are requests: update notifications in them (and on the server), and the panel follows.
  • Give system notifications an icon and people's an avatar, so the two read apart at a glance.
  • Write the title as the whole sentence — "Amina mentioned you in Checkout" — and keep body for the quote or the detail.
  • Pass href for anything with a page behind it; opening it marks it read and closes the panel.
  • Set locale (or <html lang>) so the times are in the app's language — ar, ckb and tr ship with it, and the digits stay Western in all of them.

Don't

  • Toast a notification and also put it in the center. Pick one: the moment, or the history.
  • Leave out ondismiss and then expect a swipe to do anything — without it rows cannot be dismissed, by design.
  • Put actions inside a row (Approve, Reply). A row is one press; the action belongs on the page href opens.
  • Mark everything read when the panel opens. People open it to see what is new; reading is their call.

API

NotificationCenter

The bell in the top bar, and everything behind it.

A bell with the unread count on it opens a panel: a title and "Mark all read", All and Unread tabs, and the notifications themselves — an unread dot, an avatar or an icon, the title and a line or two of body, and a time that keeps itself current ("3m", then "4m"). On a desktop the panel hangs off the bell; on a phone it is a screen of its own — a top app bar with a back arrow, sliding in from the edge the way a page does — because a popover a thumb has to reach across for is the wrong shape there, and an inbox wants the whole height and nothing else pulling on the rows.

The bell rings once when the unread count goes up, and not at all under reduced motion.

Rows are dismissed the way each device expects. A finger swipes one away — or swipes the other way to mark it read — and a mouse gets an × that appears where the time was. Either way the row folds shut before ondismiss runs, so the list closes over the gap rather than jumping.

The component never changes notifications itself: every callback is a request, and the list you pass back is the answer. Past virtualize rows the list is windowed, so a backlog of a thousand opens as fast as one of ten.

import { NotificationCenter } from 'omaris'
<NotificationCenter  {notifications}  onread={(id) => markRead(id)}  onreadall={markAllRead}  ondismiss={(id) => remove(id)}/>

Parts

Every element this renders is reachable from outside. Pass Tailwind for one part as classes={{ part: '…' }}; class covers the root and is merged last, so it always wins.

root
The box around the bell and its count — relative, so the badge can pin to it.
bell
The bell — an IconButton, so this lands on its root. It swings from the top.
badge
The unread count on the bell — a Badge.
panel
The popover's surface, on a desktop.
screen
The full-screen view on a phone — a native modal <dialog>.
header
Title and "Mark all read" — the top app bar on a phone.
back
The back arrow in the phone's top app bar — an IconButton.
heading
No description in the source yet.
markAll
"Mark all read" — a Button.
tabs
The tab set.
tabPanel
One tab's panel — the box the list scrolls inside.
list
The scrolling list.
item
One notification's wrapper — it folds shut when the row is dismissed.
row
The pressable row — a link with href, a button without.
dot
The unread dot, at the start of an unread row.
media
The avatar, or the icon well.
icon
The icon well, when a row has an icon rather than an avatar.
text
No description in the source yet.
title
No description in the source yet.
body
No description in the source yet.
time
The relative time. Gives way to the × under a hovering mouse.
dismiss
The ×. A mouse gets it on hover; a finger swipes instead.
empty
No description in the source yet.
footer
No description in the source yet.

Props

notifications required
NotificationItem[]
open bindable

Defaults to false

boolean

Whether the panel is open. Bindable.

tab bindable

Defaults to 'all'

NotificationTab

The tab showing. Bindable.

title

Defaults to 'Notifications'

string

The panel's heading, and its accessible name.

label

Defaults to 'Notifications'

string

The bell's accessible name. The unread count is added to it.

onread
(id: string) => void

A notification was opened, or swiped the other way: mark it read.

onreadall
() => void

"Mark all read" was pressed. Without it the button is not drawn.

ondismiss
(id: string) => void

A notification was dismissed — swiped away, or its × pressed. Called once the row has folded shut. Without it rows cannot be dismissed.

onselect
(notification: NotificationItem) => void

A notification was pressed, after onread.

empty
Snippet<[NotificationTab]>

Drawn when the tab has nothing in it. Handed the tab.

footer
Snippet<[{ close: () => void }]>

Along the bottom of the panel — a "See all" link, say.

icon
Snippet

Replaces the bell glyph.

locale
string

Language for the relative times. Defaults to <html lang>.

max

Defaults to 99

number

The count stops at this and adds a +.

virtualize

Defaults to 50

number

Past this many rows the list is windowed.

align

Defaults to 'end'

AnchorAlign

Which edge of the bell the popover lines up with.

mobileQuery

Defaults to '(max-width: 639px)'

string

What counts as a phone — there the panel is a full-screen view.

markAllLabel

Defaults to 'Mark all read'

string

Labels, for another language.

allLabel

Defaults to 'All'

string
unreadLabel

Defaults to 'Unread'

string
dismissLabel

Defaults to 'Dismiss'

string
readLabel

Defaults to 'Read'

string
closeLabel

Defaults to 'Back'

string

The back arrow that closes the full-screen view on a phone.

class
string
classes
NotificationCenterClasses

Per-part Tailwind overrides. class still covers the root.