Notifications Popover
blockcommunicationHeader-grade notifications panel. Slot-trigger Popover the consumer wraps around their own Bell button. Filter tabs (All/Unread), Today/Earlier groups, category-coloured accent bars, hover-dismiss, mark-all-read, slide-in animation. Slot receives `unreadCount` so the trigger can show a badge.
Installation
$pnpm dlx shadcn@latest add https://uipkge.dev/r/lit/notifications-popover.json$npx shadcn@latest add https://uipkge.dev/r/lit/notifications-popover.json$yarn dlx shadcn@latest add https://uipkge.dev/r/lit/notifications-popover.json$bunx shadcn@latest add https://uipkge.dev/r/lit/notifications-popover.jsonManifest:
https://uipkge.dev/r/lit/notifications-popover.jsonInstalls to:src/uip/blocks/notifications-popover/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.
PopoverNotificationinterface PopoverNotification {
id: string
title: string
body: string
category: PopoverNotificationCategory
timestamp: Date
read: boolean
}HTMLElementTagNameMapinterface HTMLElementTagNameMap {
'uip-notifications-popover': UipNotificationsPopover
}
}Theming
CSS custom properties referenced in this item. Override any of them in your :root or per-element to retheme.
--border--muted--popoverFiles installed (1)
src/uip/blocks/notifications-popover/notifications-popover.ts14.9 kB
import { LitElement, css, html } from 'lit' import { Archive, Bell, BellOff, Calendar, CreditCard, FileText, GraduationCap, Target, 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/popover/popover' export type PopoverNotificationCategory = 'hr' | 'payroll' | 'timeoff' | 'performance' | 'training' | 'system' interface PopoverNotification { id: string title: string body: string category: PopoverNotificationCategory timestamp: Date read: boolean } function seedNotifications(): PopoverNotification[] { const now = new Date() const min = 60 * 1000 const hour = 60 * min return [ { id: '1', title: 'Time off approved', body: 'Your annual leave request for Mar 15-18 has been approved by Sarah Connor.', category: 'timeoff', timestamp: new Date(now.getTime() - 12 * min), read: false }, { id: '2', title: 'New employee onboarded', body: 'Marcus Rivera has joined the Engineering team as Senior Developer.', category: 'hr', timestamp: new Date(now.getTime() - 45 * min), read: false }, { id: '3', title: 'Performance review due', body: 'Annual performance review for 3 direct reports is due by end of this week.', category: 'performance', timestamp: new Date(now.getTime() - 2 * hour), read: false }, { id: '4', title: 'Payroll processed', body: 'March 2026 payroll has been processed successfully. 103 employees paid.', category: 'payroll', timestamp: new Date(now.getTime() - 5 * hour), read: true }, { id: '5', title: 'Training enrollment open', body: 'New course available: "Leadership Fundamentals Q2". Enroll before Mar 20.', category: 'training', timestamp: new Date(now.getTime() - 8 * hour), read: true }, { id: '6', title: 'Document requires signature', body: 'Updated Employee Handbook 2026 needs your acknowledgement.', category: 'system', timestamp: new Date(now.getTime() - 26 * hour), read: true }, { id: '7', title: 'Time off request pending', body: 'Alice Johnson requested sick leave for Mar 12. Awaiting your approval.', category: 'timeoff', timestamp: new Date(now.getTime() - 28 * hour), read: true }, { id: '8', title: 'Benefits enrollment closing', body: "Open enrollment period ends Mar 31. 12 employees haven't enrolled yet.", category: 'hr', timestamp: new Date(now.getTime() - 48 * hour), read: true }, ] } function accent(category: PopoverNotificationCategory): string { switch (category) { case 'hr': return 'bg-success' case 'payroll': return 'bg-info' case 'timeoff': return 'bg-chart-3' case 'performance': return 'bg-warning' case 'training': return 'bg-primary' default: return 'bg-muted-foreground' } } function tileBg(category: PopoverNotificationCategory): string { switch (category) { case 'hr': return 'bg-success/10 text-success' case 'payroll': return 'bg-info/10 text-info' case 'timeoff': return 'bg-chart-3/10 text-chart-3' case 'performance': return 'bg-warning/10 text-warning' case 'training': return 'bg-primary/10 text-primary' default: return 'bg-muted text-muted-foreground' } } function categoryIcon(category: PopoverNotificationCategory, cls: string) { switch (category) { case 'hr': return icon(UserPlus, 'user-plus', cls) case 'payroll': return icon(CreditCard, 'credit-card', cls) case 'timeoff': return icon(Calendar, 'calendar', cls) case 'performance': return icon(Target, 'target', cls) case 'training': return icon(GraduationCap, 'graduation-cap', cls) default: return icon(FileText, 'file-text', cls) } } function formatTime(date: Date): string { const diffMs = Date.now() - date.getTime() const diffMin = Math.floor(diffMs / 60000) if (diffMin < 1) return 'Just now' if (diffMin < 60) return `${diffMin}m ago` const diffHrs = Math.floor(diffMin / 60) if (diffHrs < 24) return `${diffHrs}h ago` const diffDays = Math.floor(diffHrs / 24) if (diffDays === 1) return 'Yesterday' return `${diffDays}d ago` } /** * <uip-notifications-popover> — the registry NotificationsPopover block as a web component. * * Class strings are React's NotificationsPopover verbatim. React state becomes * @state fields. React's `trigger` render-prop becomes a `trigger` slot: * put your own Bell button in `slot="trigger"` (it receives the live unread * dot via the fallback); with no slotted trigger the default Bell button * renders. React's injected `<style>` (scrollbar, hover, notif-slide-in) is * copied into `static styles` (the keyframes exception covers the slide-in; * the scrollbar/fade rules ride along as React-authored block CSS). */ export class UipNotificationsPopover extends LitElement { static shadowRootOptions = { ...LitElement.shadowRootOptions, delegatesFocus: true } // :host display can't be a utility class (the host has no template of its own). // Panel + slide-in CSS is copied from React's injected <style> (keyframes exception). static styles = [ tailwind, css`:host { display: inline-block; }`, css` .notification-scroll::-webkit-scrollbar { width: 4px; } .notification-scroll::-webkit-scrollbar-track { background: transparent; } .notification-scroll::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; } .notification-item { cursor: pointer; transition: background-color 0.15s ease; animation: notif-slide-in 0.25s ease both; } .notification-item:hover { background-color: var(--muted); } @keyframes notif-slide-in { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } } .notification-scroll { position: relative; } .notification-scroll::after { content: ''; position: sticky; bottom: 0; left: 0; right: 0; display: block; height: 24px; margin-top: -24px; background: linear-gradient(to bottom, transparent, var(--popover)); pointer-events: none; } `, ] static properties = { notifications: { state: true }, activeFilter: { state: true }, isOpen: { state: true }, } private notifications: PopoverNotification[] = [] private activeFilter: 'all' | 'unread' = 'all' private isOpen = false constructor() { super() new ThemeController(this) } connectedCallback() { super.connectedCallback() this.setAttribute('data-uipkge', '') this.setAttribute('data-slot', 'notifications-popover') if (this.notifications.length === 0) this.notifications = seedNotifications() } private get unreadCount(): number { return this.notifications.filter((n) => !n.read).length } private get filtered(): PopoverNotification[] { if (this.activeFilter === 'unread') return this.notifications.filter((n) => !n.read) return this.notifications } private markAsRead(id: string) { this.notifications = this.notifications.map((n) => (n.id === id ? { ...n, read: true } : n)) } private markAllRead() { this.notifications = this.notifications.map((n) => ({ ...n, read: true })) } private dismiss(id: string, e: Event) { e.stopPropagation() this.notifications = this.notifications.filter((n) => n.id !== id) } private renderItem(n: PopoverNotification, delay: number) { return html` <div data-slot="notifications-popover" class="notification-item group relative" style="animation-delay: ${delay}ms" @click=${() => this.markAsRead(n.id)} > <div class=${cn( 'absolute top-2 bottom-2 left-0 w-[3px] rounded-r-full transition-opacity', !n.read ? accent(n.category) : 'opacity-0', )} ></div> <div class="flex gap-3 px-4 py-3"> <div class=${cn('mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg', tileBg(n.category))}> ${categoryIcon(n.category, 'size-4')} </div> <div class="min-w-0 flex-1"> <div class="flex items-start justify-between gap-2"> <p class=${cn('text-[13px] leading-snug', !n.read ? 'font-semibold' : 'font-medium')}>${n.title}</p> <div class="flex shrink-0 items-center gap-1.5"> <span class="text-muted-foreground text-xs whitespace-nowrap tabular-nums">${formatTime(n.timestamp)}</span> ${!n.read ? html`<span class="bg-primary size-1.5 shrink-0 rounded-full"></span>` : ''} </div> </div> <p class="text-muted-foreground mt-0.5 line-clamp-2 text-xs leading-relaxed">${n.body}</p> </div> <button class="text-muted-foreground hover:text-foreground mt-0.5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100" title="Dismiss" @click=${(e: Event) => this.dismiss(n.id, e)} > ${icon(X, 'x', 'size-3.5')} </button> </div> </div> ` } render() { const now = new Date() // Mirror the live count for slotted custom triggers (React's trigger({ unreadCount })). this.setAttribute('unread-count', String(this.unreadCount)) const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()) const today = this.filtered.filter((n) => n.timestamp >= todayStart) const earlier = this.filtered.filter((n) => n.timestamp < todayStart) return html` <uip-popover ?open=${this.isOpen} @open-change=${(e: CustomEvent<{ open: boolean }>) => { this.isOpen = e.detail.open }} > <slot name="trigger" slot="trigger"> <uip-button variant="ghost" size="icon" class="text-muted-foreground hover:text-foreground relative size-9 rounded-lg" aria-label="Notifications" > ${icon(Bell, 'bell', 'size-4')} ${this.unreadCount > 0 ? html`<span class="bg-primary ring-background absolute top-1.5 right-1.5 size-2 rounded-full ring-2"></span>` : ''} </uip-button> </slot> <div class="notification-panel w-full max-w-[380px] overflow-hidden rounded-lg border p-0 shadow-xl"> <div class="flex items-center justify-between px-4 pt-4 pb-3"> <div class="flex items-center gap-2.5"> <h3 class="text-sm font-semibold tracking-tight">Notifications</h3> ${this.unreadCount > 0 ? html`<uip-badge class="bg-primary/15 text-primary hover:bg-primary/15 h-5 rounded-full px-1.5 text-xs font-bold tabular-nums" >${this.unreadCount}</uip-badge >` : ''} </div> ${this.unreadCount > 0 ? html`<uip-button variant="ghost" size="sm" class="text-muted-foreground hover:text-foreground -mr-1 h-7 px-2 text-xs" @click=${this.markAllRead} > Mark all read </uip-button>` : ''} </div> <div class="border-b px-4"> <div class="flex gap-0"> <button class=${cn( 'relative px-3 pb-2.5 text-xs font-medium transition-colors', this.activeFilter === 'all' ? 'text-foreground' : 'text-muted-foreground hover:text-foreground', )} @click=${() => { this.activeFilter = 'all' }} > All ${this.activeFilter === 'all' ? html`<span class="bg-primary absolute right-0 bottom-0 left-0 h-[2px] rounded-t-full"></span>` : ''} </button> <button class=${cn( 'relative px-3 pb-2.5 text-xs font-medium transition-colors', this.activeFilter === 'unread' ? 'text-foreground' : 'text-muted-foreground hover:text-foreground', )} @click=${() => { this.activeFilter = 'unread' }} > Unread ${this.activeFilter === 'unread' ? html`<span class="bg-primary absolute right-0 bottom-0 left-0 h-[2px] rounded-t-full"></span>` : ''} </button> </div> </div> <div class="notification-scroll max-h-[420px] overflow-y-auto"> ${this.filtered.length === 0 ? html` <div class="flex flex-col items-center justify-center py-14 text-center"> <div class="bg-muted mb-3 rounded-full p-3"> ${icon(BellOff, 'bell-off', 'text-muted-foreground size-5')} </div> <p class="text-sm font-medium">All caught up</p> <p class="text-muted-foreground mt-0.5 text-xs"> No ${this.activeFilter === 'unread' ? 'unread ' : ''}notifications </p> </div> ` : html` ${today.length > 0 ? html` <div class="px-4 pt-3 pb-1"> <span class="text-muted-foreground text-xs font-semibold tracking-widest uppercase">Today</span> </div> ${today.map((n, i) => this.renderItem(n, i * 30))} ` : ''} ${earlier.length > 0 ? html` <div class="px-4 pt-3 pb-1"> <span class="text-muted-foreground text-xs font-semibold tracking-widest uppercase" >Earlier</span > </div> ${earlier.map((n, i) => this.renderItem(n, (today.length + i) * 30))} ` : ''} `} </div> <div class="border-t px-4 py-2.5"> <a href="#" class="text-muted-foreground hover:text-foreground flex items-center justify-center gap-1.5 text-xs transition-colors" @click=${() => { this.isOpen = false }} > ${icon(Archive, 'archive', 'size-3')} View all notifications </a> </div> </div> </uip-popover> ` } } customElements.get('uip-notifications-popover') || customElements.define('uip-notifications-popover', UipNotificationsPopover) declare global { interface HTMLElementTagNameMap { 'uip-notifications-popover': UipNotificationsPopover } }
Raw manifest:https://uipkge.dev/r/lit/notifications-popover.json