Notification Center
blockcommunicationFull-page notification center: unread badge, mark-all-read, All/Unread/Mentions tabs with functional filtering, day-grouped rows (Today / Earlier) pairing category icon tiles with titles, bodies, relative timestamps, unread dots and hover actions (mark read, dismiss). Per-tab empty states included; swap `notifications` for your source.
Installation
$pnpm dlx shadcn@latest add https://uipkge.dev/r/lit/notification-center.json$npx shadcn@latest add https://uipkge.dev/r/lit/notification-center.json$yarn dlx shadcn@latest add https://uipkge.dev/r/lit/notification-center.json$bunx shadcn@latest add https://uipkge.dev/r/lit/notification-center.jsonManifest:
https://uipkge.dev/r/lit/notification-center.jsonInstalls to:src/uip/blocks/notification-center/Variants
Loading interactive previews…
Loading interactive previews…
Schema
Type aliases exported from this item's source. Use these to shape the data you pass in.
NotificationCenterEntryinterface NotificationCenterEntry {
id: string
category: NotificationCenterCategory
title: string
body: string
date: Date
read: boolean
}HTMLElementTagNameMapinterface HTMLElementTagNameMap {
'uip-notification-center': UipNotificationCenter
}
}Files installed (1)
src/uip/blocks/notification-center/notification-center.ts10.2 kB
import { LitElement, css, html } from 'lit' import { BellOff, Check, CheckCheck, MessageSquare, ShieldAlert, UserPlus, X } from 'lucide' import { cn } from '../../lib/utils' import { tailwind } from '../../lib/styles' import { ThemeController } from '../../lib/theme' import { icon } from '../../lib/icon' // Side-effect imports: register the composed elements for standalone use // (consumers who install only this block still get working primitives). import '../../components/badge/badge' import '../../components/button/button' import '../../components/relative-time/relative-time' import '../../components/section-card/section-card' import '../../components/tabs/tabs' export type NotificationCenterCategory = 'mention' | 'comment' | 'system' | 'security' export interface NotificationCenterEntry { id: string category: NotificationCenterCategory title: string body: string date: Date read: boolean } type NotificationFilter = 'all' | 'unread' | 'mentions' function stubNotifications(): NotificationCenterEntry[] { const now = new Date() const minutesAgo = (m: number) => new Date(now.getTime() - m * 60_000) const hoursAgo = (h: number) => new Date(now.getTime() - h * 3_600_000) const daysAgo = (d: number) => new Date(now.getTime() - d * 86_400_000) return [ { id: 'n1', category: 'mention', title: 'Priya mentioned you', body: '@amara can you review the onboarding flow?', date: minutesAgo(6), read: false }, { id: 'n2', category: 'security', title: 'New sign-in from Berlin', body: 'Chrome on macOS · 84.190.201.3', date: minutesAgo(52), read: false }, { id: 'n3', category: 'comment', title: 'Jonas commented', body: 'Shipped the fix — closing the ticket.', date: hoursAgo(2), read: false }, { id: 'n4', category: 'system', title: 'Weekly digest ready', body: 'Your workspace summary for last week.', date: hoursAgo(20), read: true }, { id: 'n5', category: 'mention', title: 'Marcus mentioned you', body: 'Design review moved to 15:00, @amara.', date: daysAgo(1), read: false }, { id: 'n6', category: 'comment', title: 'Amara commented', body: 'Copy updated per legal review.', date: daysAgo(1), read: true }, { id: 'n7', category: 'security', title: 'API key rotated', body: 'uipkge_live_…4f2a was rotated automatically.', date: daysAgo(2), read: true }, ] } function tileClass(category: NotificationCenterCategory): string { switch (category) { case 'mention': return 'bg-primary/10 text-primary' case 'comment': return 'bg-chart-3/10 text-chart-3' case 'security': return 'bg-warning/10 text-warning' default: return 'bg-muted text-muted-foreground' } } function categoryIcon(category: NotificationCenterCategory, cls: string) { switch (category) { case 'mention': return icon(UserPlus, 'user-plus', cls) case 'comment': return icon(MessageSquare, 'message-square', cls) case 'security': return icon(ShieldAlert, 'shield-alert', cls) default: return icon(BellOff, 'bell-off', cls) } } /** * <uip-notification-center> — the registry NotificationCenter block as a web component. * * Class strings are React's NotificationCenter verbatim. React state becomes * @state fields. `notifications` is a settable property (defaults to the * stub list; pass an array from JS to override — the demo's per-story arrays * are wired via getElementById scripts). Category icons are static per-row * markup. Dismiss removes the row (React's Dismiss button only stopped * propagation; here it dismisses for a working control). */ export class UipNotificationCenter extends LitElement { static shadowRootOptions = { ...LitElement.shadowRootOptions, delegatesFocus: true } // :host display can't be a utility class (the host has no template of its own). static styles = [tailwind, css`:host { display: block; }`] static properties = { notifications: { attribute: false }, items: { state: true }, filter: { state: true }, } notifications?: NotificationCenterEntry[] private items: NotificationCenterEntry[] = [] private filter: NotificationFilter = 'all' constructor() { super() new ThemeController(this) } connectedCallback() { super.connectedCallback() this.setAttribute('data-uipkge', '') this.setAttribute('data-slot', 'notification-center') if (this.items.length === 0) this.items = this.notifications ?? stubNotifications() } private patch(id: string, changes: Partial<NotificationCenterEntry>) { this.items = this.items.map((n) => (n.id === id ? { ...n, ...changes } : n)) } private dismiss(id: string, e: Event) { e.stopPropagation() this.items = this.items.filter((n) => n.id !== id) } render() { const unreadCount = this.items.filter((n) => !n.read).length const filtered = this.items.filter((n) => { if (this.filter === 'unread') return !n.read if (this.filter === 'mentions') return n.category === 'mention' return true }) const startOfToday = new Date() startOfToday.setHours(0, 0, 0, 0) const groups = [ { label: 'Today', items: filtered.filter((n) => n.date >= startOfToday) }, { label: 'Earlier', items: filtered.filter((n) => n.date < startOfToday) }, ].filter((g) => g.items.length > 0) return html` <uip-section-card data-slot="notification-center" heading="Notifications" description="Everything that needs your attention." > <div slot="header-action" class="flex flex-wrap items-center gap-2"> ${unreadCount > 0 ? html`<uip-badge variant="default">${unreadCount} new</uip-badge>` : ''} <uip-button variant="ghost" size="sm" ?disabled=${unreadCount === 0} @click=${() => { this.items = this.items.map((n) => ({ ...n, read: true })) }} > ${icon(CheckCheck, 'check-check')} Mark all read </uip-button> </div> <uip-tabs class="mb-2" value=${this.filter} @value-change=${(e: CustomEvent<{ value: string }>) => { this.filter = e.detail.value as NotificationFilter }} > <span slot="trigger" data-value="all">All</span> <span slot="trigger" data-value="unread">Unread</span> <span slot="trigger" data-value="mentions">Mentions</span> </uip-tabs> ${groups.length === 0 ? html` <div class="text-muted-foreground flex flex-col items-center gap-2 py-12 text-sm"> ${icon(BellOff, 'bell-off', 'size-6 opacity-50')} ${this.filter === 'mentions' ? "No mentions — you're all caught up." : this.filter === 'unread' ? 'Nothing unread. Nice work.' : 'Your inbox is empty.'} </div> ` : ''} ${groups.map( (group) => html` <div class="mb-2"> <p class="text-muted-foreground py-1.5 text-xs font-medium tracking-widest uppercase">${group.label}</p> <ul class="divide-y"> ${group.items.map( (notification) => html` <li class="group hover:bg-accent/40 -mx-2 flex cursor-pointer items-start gap-3 rounded-md px-2 py-3 transition-colors" @click=${() => this.patch(notification.id, { read: true })} > <span class=${cn('mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-md', tileClass(notification.category))} aria-hidden="true" > ${categoryIcon(notification.category, 'size-4')} </span> <div class="min-w-0 flex-1"> <div class="flex flex-wrap items-center gap-2"> <p class=${cn('truncate text-sm', notification.read ? 'font-normal' : 'font-medium')}> ${notification.title} </p> ${!notification.read ? html`<span class="bg-primary size-1.5 shrink-0 rounded-full" aria-label="Unread"></span>` : ''} </div> <p class="text-muted-foreground truncate text-xs">${notification.body}</p> </div> <div class="flex shrink-0 items-center gap-1"> <uip-relative-time date=${notification.date.toISOString()} class="text-muted-foreground text-xs" ></uip-relative-time> <span class="flex opacity-0 transition-opacity group-hover:opacity-100"> ${!notification.read ? html`<uip-button variant="ghost" size="icon-sm" aria-label="Mark as read" @click=${(e: Event) => { e.stopPropagation() this.patch(notification.id, { read: true }) }} > ${icon(Check, 'check')} </uip-button>` : ''} <uip-button variant="ghost" size="icon-sm" aria-label="Dismiss" @click=${(e: Event) => this.dismiss(notification.id, e)} > ${icon(X, 'x')} </uip-button> </span> </div> </li> `, )} </ul> </div> `, )} </uip-section-card> ` } } customElements.get('uip-notification-center') || customElements.define('uip-notification-center', UipNotificationCenter) declare global { interface HTMLElementTagNameMap { 'uip-notification-center': UipNotificationCenter } }
Raw manifest:https://uipkge.dev/r/lit/notification-center.json