NotificationCentre
Bell trigger with a dropdown inbox: unread badge, grouping, and mark-as-read.
Live demo
01Bindable inbox with live state
Click the bell, then click items to mark them read or Γ to dismiss. The strip below reflects the bound array.
Unread: 2 Β· Total: 4
Empty state
With no notifications the panel shows an "all caught up" message.
Dismiss disabled (read-only feed)
Set allowDismiss={false} for feeds where items should only be marked
read, never removed.
Implementation
02<script lang="ts">
import NotificationCentre from '$lib/components/NotificationCentre.svelte';
β
const notifications = [
{ id: '1', title: 'New comment', body: 'Ada replied to your thread.', timestamp: '2026-06-06T09:30:00Z', read: false },
{ id: '2', title: 'Build passed', body: 'All checks green.', timestamp: '2026-06-05T18:00:00Z', read: true }
];
</script>
β
<NotificationCentre {notifications} now="2026-06-06T10:00:00Z" />NotificationCentre is a bell trigger with a dropdown inbox. The unread badge tracks how many items have read=false; the panel groups them into Today / Earlier by comparing each timestamp against a provided now prop (so relative time stays deterministic across SSR and tests). Clicking a row marks it read, "Mark all as read" clears every dot, and an optional Γ dismisses a row. notifications is bindable and onChange fires after each mutation for persistence.
Logic explainer
03What Does It Do? (Plain English)
NotificationCentre is the bell-and-badge you see in the corner of most apps. A bell button shows a small red badge counting how many notifications you haven't read yet. Click the bell and a dropdown inbox slides open, listing your notifications split into Today and Earlier. Each row shows an optional icon, a title, a short body, and a human-friendly timestamp like "2 hours ago", plus a dot marking unread items.
Think of it like: the little post tray on a hotel reception desk. The number of slips waiting is visible at a glance; open the tray and the newest slips sit on top, today's separated from the rest. Picking one up marks it as seen, and you can sweep the whole tray clear with one button.
Clicking a row marks just that one as read; "Mark all as read" clears every dot at once; an optional Γ dismisses a row entirely.
How It Works (Pseudo-Code)
COMPUTE unreadCount = count of notifications where read is false
COMPUTE groups:
SORT notifications newest-first by timestamp
PARTITION into "Today" (same calendar day as `now`) and "Earlier"
DROP any empty group
WHEN bell CLICKED:
TOGGLE panel open
IF now open β focus the first item on the next microtask
WHEN an item CLICKED:
REPLACE that item with a copy where read = true
CALL onChange(notifications)
WHEN "Mark all as read" CLICKED:
MAP every unread item to a read copy
CALL onChange(notifications)
WHEN dismiss (Γ) CLICKED:
FILTER the item out of the list
CALL onChange(notifications)
WHEN key pressed inside panel:
Escape β close panel, return focus to bell
ArrowDown β focus next item (wraps)
ArrowUp β focus previous item (wraps)
Home/End β focus first / last item
WHEN pointer pressed outside trigger AND panel:
close panel (without stealing focus)Deterministic Relative Time
The trickiest design decision here is time. Relative phrasing ("3 minutes ago") needs a reference "now". Reading Date.now() at module load would make the component non-deterministic β it would render differently on the server than the client (hydration mismatch) and would be impossible to snapshot in a test.
So now is a required prop: an ISO-8601 string supplied by the caller. Every timestamp is compared against it.
diffSec = (itemTime - nowTime) / 1000
choose the largest unit that fits:
< 60s β seconds
< 1h β minutes
< 1 day β hours
< 1 week β days
otherwise β weeks
format with Intl.RelativeTimeFormat('en-GB', { numeric: 'auto' })numeric: 'auto' is what turns "1 day ago" into the friendlier "yesterday". The formatter is constructed once, not per row.
Immutable Updates Keep Binding Honest
Every mutation (markRead, markAllRead, dismiss) reassigns notifications to a brand-new array rather than mutating items in place. Two reasons:
notificationsis$bindable, so a fresh reference is what propagates back throughbind:notificationsto the parent.$derivedvalues (unreadCount,groups) only recompute when the reference changes β in-place mutation would leave the badge stale.
onChange fires after each mutation so a parent can persist the new state (e.g. POST the read flag to a server) without setting up its own $effect.
State Flow Diagram
ββββββββββββββββββββββββββββ
bell click ββββββββββΆβ open = true β
β focus first item β
βββββββββββββββ¬ββββββββββββββ
β
ββββββββββββββββββββββββββββββββΌββββββββββββββββββββββββββββ
βΌ βΌ βΌ
item click ArrowUp/Down/Home/End Escape / outside
read = true move roving focus open = false
onChange() (wraps at ends) (Escape restores
β focus to bell)
βΌ
unreadCount $derived recomputes βββΆ badge + aria-live updateProps Reference
| Prop | Type | Default | Description |
|---|---|---|---|
notifications |
NotificationItem[] (bindable) |
[] |
The inbox list. Each item: { id, title, body?, timestamp, read, icon? }. |
now |
string (ISO 8601) |
β (required) | Reference time used for relative timestamps and Today/Earlier grouping. Never read from the system clock internally. |
label |
string |
'Notifications' |
Accessible name for the trigger and panel; also used in the unread aria-label. |
allowDismiss |
boolean |
true |
When true, each row shows a Γ button that removes it from the list. |
onChange |
(items: NotificationItem[]) => void |
undefined |
Called after any read/dismiss mutation with the new array. |
Edge Cases
| Scenario | Behaviour |
|---|---|
Empty notifications |
Panel shows the "You're all caught up" empty state; badge hidden. |
| Unread count over 99 | Badge displays 99+ to avoid overflowing the dot. |
Invalid ISO in timestamp or now |
relativeTime returns an empty string rather than "Invalid Date". |
| All items already read | "Mark all as read" is disabled (greyed, not focusable as an action). |
| Item dismissed while focused | Focus is not forcibly restored; remaining items keep their tab order, panel stays open. |
| Click outside while open | Panel closes without moving focus (no surprise focus jump on a stray click). |
prefers-reduced-motion |
Panel open animation and trigger transition are removed. |
| Future-dated timestamp | Intl.RelativeTimeFormat renders forward phrasing ("in 5 minutes"); grouping still uses calendar-day comparison. |
Dependencies
Zero runtime dependencies. Uses the built-in Intl.RelativeTimeFormat for timestamps and inline SVG for the bell, empty-state, and dismiss glyphs β no icon library. All styling is scoped, theme-aware via CSS custom properties.
File Structure
src/lib/components/
NotificationCentre.svelte β component (markup, logic, scoped styles)
NotificationCentre.md β this document
NotificationCentre.test.ts β vitest + @testing-library/svelte coverage
src/routes/notificationcentre/
+page.svelte β ComponentPageShell demo (badge, grouping, empty, dismiss)API
04| Prop | Type | Default | Description |
|---|---|---|---|
notifications | NotificationItem[] (bindable) | [] | The inbox list: { id, title, body?, timestamp, read, icon? }. |
now | string (ISO 8601) | β (required) | Reference time for relative timestamps and Today/Earlier grouping. |
label | string | 'Notifications' | Accessible name for the trigger and panel. |
allowDismiss | boolean | true | Show a Γ button to remove each item from the list. |
onChange | (items) => void | undefined | Called after any read/dismiss mutation with the new array. |