
3D Extruded Buildings & Urban Footprints
Real-world 3D building extrusions with dynamic sunlight shadows, terrain DEM elevations, and pitch/bearing camera controls.
Header-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 ->$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.jsonnpx shadcn-vue@latest add @uipkge/notifications-popoverInstalls to:app/components/blocks/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
}CSS custom properties referenced in this item. Override any of them in your :root or per-element to retheme.
--border--muted--popover--scrollbar-size<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 data-slot="notifications-popover" v-model:open="isOpen">
<PopoverTrigger as-child>
<slot :unread-count="unreadCount" />
</PopoverTrigger>
<PopoverContent
align="end"
:side-offset="8"
class="notification-panel w-96 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-xs 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-px 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-px rounded-t-full"
/>
</button>
</div>
</div>
<div class="notification-scroll max-h-[26.25rem] 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-xs 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-xs 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-xs 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-xs 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