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

2 unread notifications

Notifications

2 unread

Your 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.

Install
npm install @getdom/studio
vue
<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

NameTypeTSDefaultDescription
itemsarrayArray<unknown>[]Application-owned durable notification items. The shape matches db3.ai InAppItem records.
unreadCountnumbernumberAuthoritative unread count for the full scoped inbox. Omit to count unread supplied items.

Content

NameTypeTSDefaultDescription
formatTimestampfunctionFunctionOptional formatter receiving createdAt and the complete item.

Controls

NameTypeTSDefaultDescription
showRefreshbooleanbooleantrueShow the application-owned refresh action in the default header.
showArchivebooleanbooleantrueShow an archive action for every default notification row.

Labels

NameTypeTSDefaultDescription
titlestringstring'Notifications'Inbox panel heading.
triggerLabelstringstring'Open notifications'Accessible label for the default bell trigger.
emptyTitlestringstring'You’re all caught up'Empty-state heading.
emptyDescriptionstringstring'New updates will appear here as they arrive.'Empty-state supporting copy.
loadMoreLabelstringstring'Load older notifications'Pagination action label.

Layout

NameTypeTSDefaultDescription
position'bottom-end' | 'bottom-start' | 'top-end' | 'top-start'string'bottom-end'Preferred popover placement around the trigger.
panelWidthstringstring'w-[min(25rem,calc(100vw-1rem))]'Tailwind width utilities applied to the popover panel.
maxHeightstringstring'min(34rem, calc(100dvh - 9rem))'CSS maximum height for the scrolling notification list.

State

NameTypeTSDefaultDescription
loadingbooleanbooleanfalseShow initial inbox loading placeholders when no items are available.
refreshingbooleanbooleanfalseShow refresh progress without replacing already loaded items.
loadingMorebooleanbooleanfalseShow progress on the pagination action.
updatingIdsarrayArray<unknown>[]Item IDs with a durable state transition currently in flight.
hasMorebooleanbooleanfalseShow a control that requests the next application-owned page.
errorstringstring''Compact recoverable error shown above the notification list.

Auto-generated from Notification inbox.props and inline _edit hints.

Slots

NameScopeDescription
#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.
#emptyReplace the caught-up empty state.
#footer{ hasMore, loadingMore, loadMore }Replace the optional pagination footer.

Events

NamePayloadDescription
@transition({ item, transition: "read" | "unread" | "archive" })Requests an application-owned durable state transition.
@action({ item, action })Fired when the recipient follows a notification action.
@refreshRequests a fresh first page without marking any item read.
@load-moreRequests the next application-owned inbox page.
@openFired when the inbox popover opens.
@closeFired when the inbox popover closes.

Names auto-detected from defineEmits and source emit() calls; payload and description from __doc.events when present.