UIPackage

Notifications Popover

block dashboard
Edit on GitHub

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 ->

Installation

$ npx 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