Notification Center
The bell in the top bar, and everything behind it.
Walkthrough
1. 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> 2. 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> 3. 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))}/> 4. 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>