Component
Vue Notification Inbox Component
<DomNotificationInbox>A polished notification bell and durable inbox panel for application-owned in-app records, including unread, action, archive, pagination, loading, and failure-ready states.
Demo
Durable inbox with live toasts
The inbox consumes db3.ai-style durable items and emits explicit read, unread, archive, refresh, pagination, and action intents. Simulate a live arrival to see the same record appear as a transient toast without replaying inbox history.
Northstar
Customer operations
Notifications
2 unreadYour latest account and workspace updates
Your performance report is ready
The September workspace report finished processing and is ready to review.
Maya joined your workspace
Maya accepted your invitation and can now access the Northstar workspace.
Payment method needs attention
We could not renew your Pro workspace. Update the card before 24 September.
Customer export completed
Your CSV export with 2,481 customer records is available for 7 days.
npm install @getdom/studio<script setup>
import '@getdom/studio/style.css';
import { computed, ref } from 'vue';
import {
DomButton,
DomNotificationInbox,
DomToastStack,
notificationToast,
useToasts,
} from '@getdom/studio';
const notifications = ref([
{
id: '01K59REPORTREADY0000000000',
type: 'report.ready',
message: {
title: 'Your performance report is ready',
body: 'The September workspace report finished processing and is ready to review.',
severity: 'success',
presentation: 'toast',
action: { label: 'View report', href: '/data/notification-inbox#report' },
},
createdAt: '2026-09-20T09:58:00.000Z',
readAt: null,
dismissedAt: null,
archivedAt: null,
},
{
id: '01K59INVITE00000000000000',
type: 'workspace.invitation',
message: {
title: 'Maya joined your workspace',
body: 'Maya accepted your invitation and can now access the Northstar workspace.',
severity: 'info',
presentation: 'inbox',
action: { label: 'Manage members', href: '/data/notification-inbox#members' },
},
createdAt: '2026-09-20T09:14:00.000Z',
readAt: null,
dismissedAt: null,
archivedAt: null,
},
{
id: '01K59PAYMENT0000000000000',
type: 'billing.payment-failed',
message: {
title: 'Payment method needs attention',
body: 'We could not renew your Pro workspace. Update the card before 24 September.',
severity: 'warning',
presentation: 'banner',
action: { label: 'Update billing', href: '/data/notification-inbox#billing' },
},
createdAt: '2026-09-19T14:30:00.000Z',
readAt: '2026-09-19T14:35:00.000Z',
dismissedAt: null,
archivedAt: null,
},
{
id: '01K59EXPORT00000000000000',
type: 'export.completed',
message: {
title: 'Customer export completed',
body: 'Your CSV export with 2,481 customer records is available for 7 days.',
severity: 'success',
presentation: 'inbox',
action: { label: 'Download export', href: '/data/notification-inbox#export' },
},
createdAt: '2026-09-18T16:08:00.000Z',
readAt: '2026-09-18T16:11:00.000Z',
dismissedAt: null,
archivedAt: null,
},
]);
const updatingIds = ref([]);
const loadingMore = ref(false);
const hasMore = ref(true);
const { toasts, show, dismiss } = useToasts();
const unreadCount = computed(() => notifications.value.filter((item) => !item.readAt).length);
const timestampLabels = {
'01K59REPORTREADY0000000000': '2m',
'01K59INVITE00000000000000': '46m',
'01K59PAYMENT0000000000000': 'Yesterday',
'01K59EXPORT00000000000000': '2d',
'01K59SECURITY000000000000': '4d',
'01K59DIGEST00000000000000': '6d',
};
/**
* Formats demo timestamps without coupling the reusable component to a relative-time library.
*
* @param {string} _createdAt Framework ISO timestamp.
* @param {{ id: string }} item Notification item.
* @returns {string} Stable demo label.
*/
function formatTimestamp(_createdAt, item) {
return timestampLabels[item.id] || '';
}
/**
* Simulates an application-owned persisted state transition.
*
* @param {{ item: object, transition: 'read'|'unread'|'archive' }} payload Transition event.
* @returns {Promise<void>}
*/
async function transition({ item, transition }) {
updatingIds.value = [item.id];
await new Promise((resolve) => window.setTimeout(resolve, 350));
if (transition === 'archive') {
notifications.value = notifications.value.filter((entry) => entry.id !== item.id);
} else {
notifications.value = notifications.value.map((entry) => entry.id === item.id
? { ...entry, readAt: transition === 'read' ? new Date().toISOString() : null }
: entry);
}
updatingIds.value = [];
}
/**
* Adds a newly observed live arrival to both the durable inbox and transient toast stack.
*
* @returns {void}
*/
function simulateArrival() {
const item = {
id: `01K59LIVE${Date.now()}`,
type: 'automation.completed',
message: {
title: 'Automation finished',
body: 'Weekly customer health scores have been recalculated for 128 accounts.',
severity: 'success',
presentation: 'toast',
action: { label: 'View run', href: '/data/notification-inbox#automation' },
},
createdAt: new Date().toISOString(),
readAt: null,
dismissedAt: null,
archivedAt: null,
};
timestampLabels[item.id] = 'Now';
notifications.value = [item, ...notifications.value];
show(notificationToast(item));
}
/**
* Simulates cursor pagination while preserving the current inbox rows.
*
* @returns {Promise<void>}
*/
async function loadMore() {
loadingMore.value = true;
await new Promise((resolve) => window.setTimeout(resolve, 500));
notifications.value = [
...notifications.value,
{
id: '01K59SECURITY000000000000',
type: 'security.new-device',
message: { title: 'New device signed in', body: 'Safari on macOS signed in from London, United Kingdom.', severity: 'info', presentation: 'inbox' },
createdAt: '2026-09-16T08:12:00.000Z',
readAt: '2026-09-16T08:20:00.000Z',
dismissedAt: null,
archivedAt: null,
},
{
id: '01K59DIGEST00000000000000',
type: 'workspace.digest',
message: { title: 'Your weekly workspace digest', body: '12 projects moved forward and 3 items need your attention.', severity: 'info', presentation: 'inbox', action: { label: 'Open digest', href: '/data/notification-inbox#digest' } },
createdAt: '2026-09-14T07:00:00.000Z',
readAt: '2026-09-14T09:05:00.000Z',
dismissedAt: null,
archivedAt: null,
},
];
hasMore.value = false;
loadingMore.value = false;
}
</script>
<template>
<div class="rounded-2xl border border-border bg-secondary/25 p-4 sm:p-6">
<div class="flex items-center justify-between gap-4 rounded-xl border border-border bg-canvas px-4 py-3 shadow-sm">
<div>
<p class="text-sm font-semibold">Northstar</p>
<p class="text-xs text-muted-fg">Customer operations</p>
</div>
<div class="flex items-center gap-2">
<DomButton size="sm" variant="secondary" @click="simulateArrival">Simulate live notification</DomButton>
<DomNotificationInbox
:items="notifications"
:unread-count="unreadCount"
:updating-ids="updatingIds"
:has-more="hasMore"
:loading-more="loadingMore"
:format-timestamp="formatTimestamp"
@transition="transition"
@load-more="loadMore"
/>
</div>
</div>
<DomToastStack :toasts="toasts" position="bottom-right" @dismiss="dismiss" />
</div>
</template>
Contract
Presentation stays separate from persistence
Framework-owned records
Pass the safe recipient-facing item returned by app().inApp.inbox(). Title and body render as plain text.
Application-owned transport
HTTP, authorization, WebSocket invalidation, pagination cursors, retries, and optimistic policy remain outside the component.
Independent recipient state
Read, banner dismissal, and archive stay separate. The inbox only emits transitions the current framework API supports.
Reference
Props
Control props
| Name | Type | TS | Default | Description |
|---|---|---|---|---|
items | array | Array<unknown> | [] | Application-owned durable notification items. The shape matches db3.ai InAppItem records. |
unreadCount | number | number | — | Authoritative unread count for the full scoped inbox. Omit to count unread supplied items. |
Content
| Name | Type | TS | Default | Description |
|---|---|---|---|---|
formatTimestamp | function | Function | — | Optional formatter receiving createdAt and the complete item. |
Controls
| Name | Type | TS | Default | Description |
|---|---|---|---|---|
showRefresh | boolean | boolean | true | Show the application-owned refresh action in the default header. |
showArchive | boolean | boolean | true | Show an archive action for every default notification row. |
Labels
| Name | Type | TS | Default | Description |
|---|---|---|---|---|
title | string | string | 'Notifications' | Inbox panel heading. |
triggerLabel | string | string | 'Open notifications' | Accessible label for the default bell trigger. |
emptyTitle | string | string | 'You’re all caught up' | Empty-state heading. |
emptyDescription | string | string | 'New updates will appear here as they arrive.' | Empty-state supporting copy. |
loadMoreLabel | string | string | 'Load older notifications' | Pagination action label. |
Layout
| Name | Type | TS | Default | Description |
|---|---|---|---|---|
position | 'bottom-end' | 'bottom-start' | 'top-end' | 'top-start' | string | 'bottom-end' | Preferred popover placement around the trigger. |
panelWidth | string | string | 'w-[min(25rem,calc(100vw-1rem))]' | Tailwind width utilities applied to the popover panel. |
maxHeight | string | string | 'min(34rem, calc(100dvh - 9rem))' | CSS maximum height for the scrolling notification list. |
State
| Name | Type | TS | Default | Description |
|---|---|---|---|---|
loading | boolean | boolean | false | Show initial inbox loading placeholders when no items are available. |
refreshing | boolean | boolean | false | Show refresh progress without replacing already loaded items. |
loadingMore | boolean | boolean | false | Show progress on the pagination action. |
updatingIds | array | Array<unknown> | [] | Item IDs with a durable state transition currently in flight. |
hasMore | boolean | boolean | false | Show a control that requests the next application-owned page. |
error | string | string | '' | Compact recoverable error shown above the notification list. |
Auto-generated from Notification inbox.props and inline _edit hints.
Slots
| Name | Scope | Description |
|---|---|---|
| #trigger | { unreadCount, open } | Replace the default bell trigger while retaining the inbox popover. |
| #header | { unreadCount, refresh } | Replace the panel heading and refresh control. |
| #item | { item, unread, transition, action } | Replace one notification row while retaining the panel list and state helpers. |
| #item-icon | { item, tone } | Replace the semantic icon shown for one default notification row. |
| #empty | — | Replace the caught-up empty state. |
| #footer | { hasMore, loadingMore, loadMore } | Replace the optional pagination footer. |
Events
| Name | Payload | Description |
|---|---|---|
| @transition | ({ item, transition: "read" | "unread" | "archive" }) | Requests an application-owned durable state transition. |
| @action | ({ item, action }) | Fired when the recipient follows a notification action. |
| @refresh | — | Requests a fresh first page without marking any item read. |
| @load-more | — | Requests the next application-owned inbox page. |
| @open | — | Fired when the inbox popover opens. |
| @close | — | Fired when the inbox popover closes. |
Names auto-detected from defineEmits and source emit() calls; payload and description from __doc.events when present.