Notifications Popover
block dashboardHeader-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.
Also available for React ->Installation
$ pnpm dlx shadcn-vue@latest add https://uipkge.dev/r/vue/notifications-popover.json$ npx shadcn-vue@latest add https://uipkge.dev/r/vue/notifications-popover.json$ yarn dlx shadcn-vue@latest add https://uipkge.dev/r/vue/notifications-popover.json$ bunx shadcn-vue@latest add https://uipkge.dev/r/vue/notifications-popover.json
Or with the named registry:
npx shadcn-vue@latest add @uipkge/notifications-popover
Examples
Schema
Type aliases exported from this item's source. Use these to shape the data you pass in.
Notification interface Notification {
id: string
title: string
body: string
category: NotificationCategory
timestamp: Date
read: boolean
actionUrl?: string
actor?: string
} npm dependencies
Used by
Theming
CSS custom properties referenced in this item. Override any of them in your :root or per-element to retheme.
--border--muted--popover--scrollbar-size Files (1)
-
app/components/blocks/NotificationsPopover.vue 13.6 kB
<script setup lang="ts"> import { ref, computed } from 'vue' import { UserPlus, Calendar, CreditCard, FileText, GraduationCap, Target, X, BellOff, Archive } from 'lucide-vue-next' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' type NotificationCategory = 'hr' | 'payroll' | 'timeoff' | 'performance' | 'training' | 'system' interface Notification { id: string title: string body: string category: NotificationCategory timestamp: Date read: boolean actionUrl?: string actor?: string } const categoryConfig: Record<NotificationCategory, { icon: any; 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 notifications = ref<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 activeFilter = ref<'all' | 'unread'>('all') const isOpen = ref(false) const unreadCount = computed(() => notifications.value.filter((n) => !n.read).length) const filteredNotifications = computed(() => { if (activeFilter.value === 'unread') { return notifications.value.filter((n) => !n.read) } return notifications.value }) const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()) const groupedNotifications = computed(() => { const today = filteredNotifications.value.filter((n) => n.timestamp >= todayStart) const earlier = filteredNotifications.value.filter((n) => n.timestamp < todayStart) return { today, earlier } }) 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` } function markAsRead(id: string) { const n = notifications.value.find((n) => n.id === id) if (n) n.read = true } function markAllRead() { notifications.value.forEach((n) => (n.read = true)) } function dismissNotification(id: string) { notifications.value = notifications.value.filter((n) => n.id !== id) } </script> <template> <Popover v-model:open="isOpen"> <PopoverTrigger as-child> <slot :unread-count="unreadCount" /> </PopoverTrigger> <PopoverContent align="end" :side-offset="8" class="notification-panel 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> <Badge v-if="unreadCount > 0" class="bg-primary/15 text-primary hover:bg-primary/15 h-5 rounded-full px-1.5 text-[10px] font-bold tabular-nums" > {{ unreadCount }} </Badge> </div> <Button v-if="unreadCount > 0" 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="[ 'relative px-3 pb-2.5 text-xs font-medium transition-colors', activeFilter === 'all' ? 'text-foreground' : 'text-muted-foreground hover:text-foreground', ]" @click="activeFilter = 'all'" > All <span v-if="activeFilter === 'all'" class="bg-primary absolute right-0 bottom-0 left-0 h-[2px] rounded-t-full" /> </button> <button :class="[ 'relative px-3 pb-2.5 text-xs font-medium transition-colors', activeFilter === 'unread' ? 'text-foreground' : 'text-muted-foreground hover:text-foreground', ]" @click="activeFilter = 'unread'" > Unread <span v-if="activeFilter === 'unread'" class="bg-primary absolute right-0 bottom-0 left-0 h-[2px] rounded-t-full" /> </button> </div> </div> <div class="notification-scroll max-h-[420px] overflow-y-auto"> <div v-if="filteredNotifications.length === 0" class="flex flex-col items-center justify-center py-14 text-center" > <div class="bg-muted mb-3 rounded-full p-3"> <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> <template v-else> <template v-if="groupedNotifications.today.length > 0"> <div class="px-4 pt-3 pb-1"> <span class="text-muted-foreground text-[10px] font-semibold tracking-widest uppercase">Today</span> </div> <div v-for="(n, index) in groupedNotifications.today" :key="n.id" class="notification-item group relative" :style="{ animationDelay: `${index * 30}ms` }" @click="markAsRead(n.id)" > <div :class="[ 'absolute top-2 bottom-2 left-0 w-[3px] rounded-r-full transition-opacity', !n.read ? categoryConfig[n.category].accent : 'opacity-0', ]" /> <div class="flex gap-3 px-4 py-3"> <div :class="[ 'mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg', categoryConfig[n.category].bg, ]" > <component :is="categoryConfig[n.category].icon" class="size-4" /> </div> <div class="min-w-0 flex-1"> <div class="flex items-start justify-between gap-2"> <p :class="['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-[10px] whitespace-nowrap tabular-nums"> {{ formatTime(n.timestamp) }} </span> <span v-if="!n.read" class="bg-primary size-1.5 shrink-0 rounded-full" /> </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.stop="dismissNotification(n.id)" > <X class="size-3.5" /> </button> </div> </div> </template> <template v-if="groupedNotifications.earlier.length > 0"> <div class="px-4 pt-3 pb-1"> <span class="text-muted-foreground text-[10px] font-semibold tracking-widest uppercase">Earlier</span> </div> <div v-for="(n, index) in groupedNotifications.earlier" :key="n.id" class="notification-item group relative" :style="{ animationDelay: `${(groupedNotifications.today.length + index) * 30}ms` }" @click="markAsRead(n.id)" > <div :class="[ 'absolute top-2 bottom-2 left-0 w-[3px] rounded-r-full transition-opacity', !n.read ? categoryConfig[n.category].accent : 'opacity-0', ]" /> <div class="flex gap-3 px-4 py-3"> <div :class="[ 'mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg', categoryConfig[n.category].bg, ]" > <component :is="categoryConfig[n.category].icon" class="size-4" /> </div> <div class="min-w-0 flex-1"> <div class="flex items-start justify-between gap-2"> <p :class="['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-[10px] whitespace-nowrap tabular-nums"> {{ formatTime(n.timestamp) }} </span> <span v-if="!n.read" class="bg-primary size-1.5 shrink-0 rounded-full" /> </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.stop="dismissNotification(n.id)" > <X class="size-3.5" /> </button> </div> </div> </template> </template> </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 = false" > <Archive class="size-3" /> View all notifications </a> </div> </PopoverContent> </Popover> </template> <style scoped> .notification-panel { --scrollbar-size: 4px; } .notification-scroll::-webkit-scrollbar { width: var(--scrollbar-size); } .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; } </style>
Raw manifest: https://uipkge.dev/r/vue/notifications-popover.json