Menu

Change language

GitHub repo

Notifications Popover

blockcommunication

Header-grade notifications panel. Snippet-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. Children receive trigger `props` plus `unreadCount` so the trigger can show a badge.

Installation

$npx shadcn-svelte@latest add https://uipkge.dev/r/svelte/notifications-popover.json
Manifest:https://uipkge.dev/r/svelte/notifications-popover.jsonInstalls to:src/lib/components/blocks/notifications-popover/

Variants

Loading interactive previews…

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 installed (1)

  • src/lib/components/blocks/notifications-popover/NotificationsPopover.svelte12.2 kB
    <script lang="ts" module>
      import type { Component, Snippet } from 'svelte'
    
      export interface NotificationsPopoverProps {
        /** The consumer's own trigger (e.g. their Bell button). Receives the
         *  trigger `props` (spread them onto your element) and the live
         *  `unreadCount` for the badge. */
        children?: Snippet<[{ props: Record<string, unknown>; unreadCount: number }]>
      }
    </script>
    
    <script lang="ts">
      import {
        Archive,
        BellOff,
        Calendar,
        CreditCard,
        FileText,
        GraduationCap,
        Target,
        UserPlus,
        X,
      } from '@lucide/svelte'
      import { Badge } from '$lib/components/ui/badge'
      import { Button } from '$lib/components/ui/button'
      import { Popover, PopoverContent, PopoverTrigger } from '$lib/components/ui/popover'
    
      let { children }: NotificationsPopoverProps = $props()
    
      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: Component; 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()
    
      let notifications = $state<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,
        },
      ])
    
      let activeFilter = $state<'all' | 'unread'>('all')
      let isOpen = $state(false)
    
      const unreadCount = $derived(notifications.filter((n) => !n.read).length)
    
      const filteredNotifications = $derived(
        activeFilter === 'unread' ? notifications.filter((n) => !n.read) : notifications,
      )
    
      const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
    
      const groupedNotifications = $derived.by(() => {
        const today = filteredNotifications.filter((n) => n.timestamp >= todayStart)
        const earlier = filteredNotifications.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.find((n) => n.id === id)
        if (n) n.read = true
      }
    
      function markAllRead() {
        notifications.forEach((n) => (n.read = true))
      }
    
      function dismissNotification(id: string) {
        notifications = notifications.filter((n) => n.id !== id)
      }
    </script>
    
    {#snippet notificationRow(n: Notification, index: number)}
      {@const config = categoryConfig[n.category]}
      {@const RowIcon = config.icon}
      <div
        class="notification-item group relative"
        style="animation-delay: {index * 30}ms"
        onclick={() => markAsRead(n.id)}
        onkeydown={(e) => e.key === 'Enter' && markAsRead(n.id)}
        role="button"
        tabindex="0"
      >
        <div
          class="absolute top-2 bottom-2 left-0 w-[3px] rounded-r-full transition-opacity {!n.read
            ? config.accent
            : 'opacity-0'}"
        ></div>
    
        <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 {config.bg}">
            <RowIcon 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>
                {#if !n.read}
                  <span class="bg-primary size-1.5 shrink-0 rounded-full"></span>
                {/if}
              </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"
            onclick={(e) => {
              e.stopPropagation()
              dismissNotification(n.id)
            }}
          >
            <X class="size-3.5" />
          </button>
        </div>
      </div>
    {/snippet}
    
    <Popover bind:open={isOpen}>
      <PopoverTrigger>
        {#snippet child({ props })}
          {@render children?.({ props, unreadCount })}
        {/snippet}
      </PopoverTrigger>
      <!-- data-slot lives on the panel: Popover itself renders no element. -->
      <PopoverContent
        data-slot="notifications-popover"
        align="end"
        sideOffset={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>
            {#if unreadCount > 0}
              <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}
              </Badge>
            {/if}
          </div>
          {#if unreadCount > 0}
            <Button
              variant="ghost"
              size="sm"
              class="text-muted-foreground hover:text-foreground -mr-1 h-7 px-2 text-xs"
              onclick={markAllRead}
            >
              Mark all read
            </Button>
          {/if}
        </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'}"
              onclick={() => (activeFilter = 'all')}
            >
              All
              {#if activeFilter === 'all'}
                <span class="bg-primary absolute right-0 bottom-0 left-0 h-px rounded-t-full"></span>
              {/if}
            </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'}"
              onclick={() => (activeFilter = 'unread')}
            >
              Unread
              {#if activeFilter === 'unread'}
                <span class="bg-primary absolute right-0 bottom-0 left-0 h-px rounded-t-full"></span>
              {/if}
            </button>
          </div>
        </div>
    
        <div class="notification-scroll max-h-[26.25rem] overflow-y-auto">
          {#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">
                <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>
              {#each groupedNotifications.today as n, index (n.id)}
                {@render notificationRow(n, index)}
              {/each}
            {/if}
    
            {#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>
              {#each groupedNotifications.earlier as n, index (n.id)}
                {@render notificationRow(n, groupedNotifications.today.length + index)}
              {/each}
            {/if}
          {/if}
        </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"
            onclick={() => (isOpen = false)}
          >
            <Archive class="size-3" />
            View all notifications
          </a>
        </div>
      </PopoverContent>
    </Popover>
    
    <style>
      /* Thin scrollbar, staggered slide-in rows, and the bottom fade that hints
         at more content. Keyframes, scrollbar, and ::after fade can't be
         Tailwind utilities, so they live here. */
      .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/svelte/notifications-popover.json