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 uipkge-ng@latest add notifications-popover$npx uipkge-ng@latest add notifications-popover$yarn dlx uipkge-ng@latest add notifications-popover$bunx uipkge-ng@latest add notifications-popoverManifest:
https://uipkge.dev/r/angular/notifications-popover.jsonInstalls to:src/app/components/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.
Notificationinterface Notification {
id: string
title: string
body: string
category: NotificationCategory
timestamp: Date
read: boolean
actionUrl?: string
actor?: string
}NotificationsPopoverTriggerContextinterface NotificationsPopoverTriggerContext {
$implicit: number
unreadCount: number
}npm dependencies
Theming
CSS custom properties referenced in this item. Override any of them in your :root or per-element to retheme.
--popoverFiles installed (2)
src/app/components/blocks/notifications-popover/notifications-popover.component.ts15.7 kB
import { Component, ContentChild, Injector, TemplateRef, ViewChild, computed, signal, ChangeDetectionStrategy, } from '@angular/core' import { NgTemplateOutlet } from '@angular/common' import { Archive, BellOff, Calendar, CreditCard, FileText, GraduationCap, LucideAngularModule, Target, UserPlus, X, type LucideIconData, } from 'lucide-angular' import { cn } from '@/lib/utils' import { UiBadgeComponent } from '@/ui/badge/badge.component' import { UiButtonComponent } from '@/ui/button/button.component' import { UiPopoverComponent, UiPopoverContentComponent, UiPopoverTriggerComponent, } from '@/ui/popover/popover.component' export type NotificationCategory = 'hr' | 'payroll' | 'timeoff' | 'performance' | 'training' | 'system' export interface Notification { id: string title: string body: string category: NotificationCategory timestamp: Date read: boolean actionUrl?: string actor?: string } /** Context of the trigger template: `let-unreadCount="unreadCount"` (or `let-count` for $implicit). */ export interface NotificationsPopoverTriggerContext { $implicit: number unreadCount: number } const categoryConfig: Record<NotificationCategory, { icon: LucideIconData; accent: string; bg: string }> = { hr: { icon: UserPlus, accent: 'bg-success', bg: 'bg-success/10 text-success', }, payroll: { icon: CreditCard, accent: 'bg-info', bg: 'bg-info/10 text-info', }, timeoff: { icon: Calendar, accent: 'bg-chart-3', bg: 'bg-chart-3/10 text-chart-3', }, performance: { icon: Target, accent: 'bg-warning', bg: 'bg-warning/10 text-warning', }, training: { icon: GraduationCap, accent: 'bg-primary', bg: 'bg-primary/10 text-primary', }, system: { icon: FileText, accent: 'bg-muted-foreground', bg: 'bg-muted text-muted-foreground', }, } const now = new Date() const initialNotifications: Notification[] = [ { 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 * 60 * 1000), read: false, actor: 'Sarah Connor', }, { 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 * 60 * 1000), read: false, actor: 'Marcus Rivera', }, { 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 * 60 * 60 * 1000), 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 * 60 * 60 * 1000), 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 * 60 * 60 * 1000), read: true, }, { id: '6', title: 'Document requires signature', body: 'Updated Employee Handbook 2026 needs your acknowledgement.', category: 'system', timestamp: new Date(now.getTime() - 26 * 60 * 60 * 1000), read: true, actionUrl: '/documents', }, { 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 * 60 * 60 * 1000), read: true, actor: 'Alice Johnson', }, { 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 * 60 * 60 * 1000), read: true, }, ] const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()) function formatTime(date: Date): string { const diffMs = now.getTime() - 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` } /** * NotificationsPopover -- the React block, 1:1. A header-grade notifications panel: * All / Unread filter tabs, Today / Earlier groups, category-coloured accent bars, * hover-dismiss, mark-all-read and a staggered slide-in. * * Bring your own trigger (React's `trigger` render prop, Vue's scoped slot): project an * `<ng-template>` whose context carries the live `unreadCount`, and put * `ui-popover-trigger` on the element -- the Radix `asChild` equivalent, so the button * itself gets aria-expanded / data-state and focus returns to it on close: * * ```html * <ui-notifications-popover> * <ng-template let-unreadCount="unreadCount"> * <button ui-button ui-popover-trigger variant="ghost" size="icon" aria-label="Notifications"> * <lucide-icon [img]="Bell" class="size-4" /> * @if (unreadCount > 0) { <span class="bg-primary absolute top-1.5 right-1.5 size-2 rounded-full"></span> } * </button> * </ng-template> * </ui-notifications-popover> * ``` * * The React block injects a small stylesheet (scrollbar, hover row, slide-in keyframe, * bottom fade); here the same rules are Tailwind utilities, the keyframe coming from * tw-animate-css (`animate-in fade-in-0 slide-in-from-bottom-1`), which Popover already uses. */ @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'ui-notifications-popover, [ui-notifications-popover]', standalone: true, // React renders no wrapper (Popover root has no element): keep the host out of layout. host: { class: 'contents' }, imports: [ NgTemplateOutlet, LucideAngularModule, UiBadgeComponent, UiButtonComponent, UiPopoverComponent, UiPopoverTriggerComponent, UiPopoverContentComponent, ], template: ` <ui-popover [open]="isOpen()" (openChange)="isOpen.set($event)"> @if (triggerTemplate) { <ng-container *ngTemplateOutlet=" triggerTemplate; context: { $implicit: unreadCount(), unreadCount: unreadCount() }; injector: triggerInjector " /> } @else { <span ui-popover-trigger></span> } <!-- React says w-full, but Radix's popper wrapper has min-width: max-content, so the panel is content-sized up to 380px (the empty state is narrow). Our Popover positions the panel itself, where w-full would mean the viewport: w-max reproduces React's width. --> <ui-popover-content align="end" [sideOffset]="8" class="notification-panel w-max 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> @if (unreadCount() > 0) { <span ui-badge class="bg-primary/15 text-primary hover:bg-primary/15 h-5 rounded-full px-1.5 text-xs font-bold tabular-nums" > {{ unreadCount() }} </span> } </div> @if (unreadCount() > 0) { <button ui-button variant="ghost" size="sm" class="text-muted-foreground hover:text-foreground -mr-1 h-7 px-2 text-xs" (click)="markAllRead()" > Mark all read </button> } </div> <div class="border-b px-4"> <div class="flex gap-0"> <button [class]="tabClass('all')" (click)="activeFilter.set('all')"> All @if (activeFilter() === 'all') { <span class="bg-primary absolute right-0 bottom-0 left-0 h-[2px] rounded-t-full"></span> } </button> <button [class]="tabClass('unread')" (click)="activeFilter.set('unread')"> Unread @if (activeFilter() === 'unread') { <span class="bg-primary absolute right-0 bottom-0 left-0 h-[2px] rounded-t-full"></span> } </button> </div> </div> <div class="notification-scroll [&::-webkit-scrollbar-thumb]:bg-border relative max-h-[420px] overflow-y-auto after:pointer-events-none after:sticky after:right-0 after:bottom-0 after:left-0 after:-mt-6 after:block after:h-6 after:bg-[linear-gradient(to_bottom,transparent,var(--popover))] after:content-[''] [&::-webkit-scrollbar]:w-1 [&::-webkit-scrollbar-thumb]:rounded-[2px] [&::-webkit-scrollbar-track]:bg-transparent" > @if (filteredNotifications().length === 0) { <div class="flex flex-col items-center justify-center py-14 text-center"> <div class="bg-muted mb-3 rounded-full p-3"> <lucide-icon [img]="BellOff" class="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 {{ activeFilter() === 'unread' ? 'unread ' : '' }}notifications </p> </div> } @else { @if (groupedNotifications().today.length > 0) { <div class="px-4 pt-3 pb-1"> <span class="text-muted-foreground text-xs font-semibold tracking-widest uppercase">Today</span> </div> @for (n of groupedNotifications().today; track n.id; let index = $index) { <ng-container *ngTemplateOutlet="item; context: { $implicit: n, delay: index * 30 }" /> } } @if (groupedNotifications().earlier.length > 0) { <div class="px-4 pt-3 pb-1"> <span class="text-muted-foreground text-xs font-semibold tracking-widest uppercase">Earlier</span> </div> @for (n of groupedNotifications().earlier; track n.id; let index = $index) { <ng-container *ngTemplateOutlet=" item; context: { $implicit: n, delay: (groupedNotifications().today.length + index) * 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)="isOpen.set(false)" > <lucide-icon [img]="Archive" class="size-3" /> View all notifications </a> </div> </ui-popover-content> </ui-popover> <ng-template #item let-n let-delay="delay"> <div data-slot="notifications-popover" class="notification-item group animate-in fade-in-0 slide-in-from-bottom-1 animation-duration-250 fill-mode-both [&:hover]:bg-muted relative cursor-pointer transition-[background-color] duration-150 ease-[ease]" [style.animation-delay.ms]="delay" (click)="markAsRead(n.id)" > <div [class]=" cn( 'absolute top-2 bottom-2 left-0 w-[3px] rounded-r-full transition-opacity', !n.read ? categoryStyle(n).accent : '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', categoryStyle(n).bg)"> <lucide-icon [img]="categoryStyle(n).icon" class="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> @if (!n.read) { <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 type="button" class="text-muted-foreground hover:text-foreground focus-visible:ring-ring mt-0.5 shrink-0 rounded opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:outline-none" title="Dismiss" aria-label="Dismiss" (click)="$event.stopPropagation(); dismissNotification(n.id)" > <lucide-icon [img]="X" class="size-3.5" aria-hidden="true" /> </button> </div> </div> </ng-template> `, }) export class UiNotificationsPopoverComponent { /** Your trigger: an `<ng-template>` receiving `unreadCount`; mark its element `ui-popover-trigger`. */ @ContentChild(TemplateRef) triggerTemplate?: TemplateRef<NotificationsPopoverTriggerContext> @ViewChild(UiPopoverComponent, { static: true }) private popover!: UiPopoverComponent /** Lets `ui-popover-trigger` inside the consumer's template find this block's Popover root. */ readonly triggerInjector = Injector.create({ providers: [{ provide: UiPopoverComponent, useFactory: () => this.popover }], }) protected readonly Archive = Archive protected readonly BellOff = BellOff protected readonly X = X protected readonly config = categoryConfig protected readonly cn = cn protected readonly formatTime = formatTime readonly notifications = signal<Notification[]>(initialNotifications) readonly activeFilter = signal<'all' | 'unread'>('all') readonly isOpen = signal(false) readonly unreadCount = computed(() => this.notifications().filter((n) => !n.read).length) readonly filteredNotifications = computed(() => { if (this.activeFilter() === 'unread') { return this.notifications().filter((n) => !n.read) } return this.notifications() }) /** * The row template's `let-n` is untyped (`any`) in an ng-template context, and indexing the * config Record with it fails a strict consumer app (TS7053). A typed accessor keeps it typed. */ protected categoryStyle(n: Notification) { return this.config[n.category] } readonly groupedNotifications = computed(() => { const today = this.filteredNotifications().filter((n) => n.timestamp >= todayStart) const earlier = this.filteredNotifications().filter((n) => n.timestamp < todayStart) return { today, earlier } }) protected tabClass(filter: 'all' | 'unread'): string { return cn( 'focus-visible:ring-ring relative rounded-t px-3 pb-2.5 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none', this.activeFilter() === filter ? 'text-foreground' : 'text-muted-foreground hover:text-foreground', ) } markAsRead(id: string): void { this.notifications.update((prev) => prev.map((n) => (n.id === id ? { ...n, read: true } : n))) } markAllRead(): void { this.notifications.update((prev) => prev.map((n) => ({ ...n, read: true }))) } dismissNotification(id: string): void { this.notifications.update((prev) => prev.filter((n) => n.id !== id)) } }src/app/components/blocks/notifications-popover/index.ts0.2 kB
export { UiNotificationsPopoverComponent, type Notification, type NotificationCategory, type NotificationsPopoverTriggerContext, } from './notifications-popover.component'
Raw manifest:https://uipkge.dev/r/angular/notifications-popover.json