Skip to main content
Urbicon UI
Back to Recipes

Notification Center

A bell button whose badge counts unread notifications, opening a drawer with All and Unread tabs, mark-as-read and archive per item, and a mark-all footer.

Built with Drawer Tab Badge Avatar Button Tooltip

Live preview

NotificationBell.svelte

Open the drawer from the bell. Marking items read counts both badges down and empties the Unread tab; archiving removes an item for good.
<script lang="ts">
  import {
    ArchiveIcon,
    Avatar,
    Badge,
    BellIcon,
    Button,
    CheckIcon,
    Drawer,
    Tab,
    TabItem,
    TabPanel,
    Tooltip
  } from '@urbicon-ui/blocks';

  interface Notification {
    id: string;
    title: string;
    description: string;
    sender: string;
    time: string;
    read: boolean;
    type: 'info' | 'success' | 'warning';
  }

  let drawerOpen = $state(false);
  let activeTab = $state('all');

  let notifications = $state<Notification[]>([/* … your feed … */]);

  // Both badges, the Unread panel and the footer's disabled state derive from
  // the one notifications array; nothing keeps a second list.
  let unreadCount = $derived(notifications.filter((n) => !n.read).length);
  let unreadNotifications = $derived(notifications.filter((n) => !n.read));

  // Your API calls slot in here; the drawer re-renders from the array alone.
  function markAsRead(id: string) {
    notifications = notifications.map((n) => (n.id === id ? { ...n, read: true } : n));
  }

  function archiveNotification(id: string) {
    notifications = notifications.filter((n) => n.id !== id);
  }

  function markAllAsRead() {
    notifications = notifications.map((n) => ({ ...n, read: true }));
  }

  // The unread dot's colour, by notification type.
  const typeColors: Record<Notification['type'], string> = {
    info: 'bg-primary-subtle',
    success: 'bg-success-subtle',
    warning: 'bg-warning-subtle'
  };
</script>

<!-- One item template, two filters: both tab panels render this. -->
{#snippet list(items: Notification[])}
  {#if items.length === 0}
    <div class="flex flex-col items-center py-12">
      <BellIcon size={40} class="text-text-quaternary mb-3" />
      <p class="text-text-secondary text-sm font-medium">All caught up</p>
    </div>
  {:else}
    <ul class="divide-border-hairline divide-y">
      {#each items as notification (notification.id)}
        <li
          class={[
            'flex gap-3 px-1 py-3 transition-opacity duration-[var(--blocks-duration-normal)]',
            notification.read && 'opacity-60'
          ]}
        >
          <Avatar name={notification.sender} size="sm" />
          <div class="min-w-0 flex-1">
            <div class="flex items-start justify-between gap-2">
              <p class="text-text-primary text-sm font-medium">{notification.title}</p>
              {#if !notification.read}
                <span
                  class={['mt-1.5 h-2 w-2 shrink-0 rounded-full', typeColors[notification.type]]}
                ></span>
              {/if}
            </div>
            <p class="text-text-secondary mt-0.5 text-xs">{notification.description}</p>
            <p class="text-text-quaternary mt-1 text-xs">
              {notification.sender} &middot; {notification.time}
            </p>
          </div>
          <div class="flex shrink-0 items-start gap-1">
            {#if !notification.read}
              <Tooltip label="Mark as read">
                <button
                  class="text-text-tertiary hover:bg-surface-hover hover:text-primary focus-visible:ring-primary/50 rounded p-1 transition-colors focus-visible:ring-2 focus-visible:outline-none"
                  onclick={() => markAsRead(notification.id)}
                  aria-label="Mark as read"
                >
                  <CheckIcon size={16} />
                </button>
              </Tooltip>
            {/if}
            <Tooltip label="Archive">
              <button
                class="text-text-tertiary hover:bg-surface-hover hover:text-primary focus-visible:ring-primary/50 rounded p-1 transition-colors focus-visible:ring-2 focus-visible:outline-none"
                onclick={() => archiveNotification(notification.id)}
                aria-label="Archive"
              >
                <ArchiveIcon size={16} />
              </button>
            </Tooltip>
          </div>
        </li>
      {/each}
    </ul>
  {/if}
{/snippet}

<!-- The bell sits wherever your header puts it; the drawer mounts in the top
     layer, so nothing around the trigger has to make room. -->
<Button variant="outlined" intent="neutral" onclick={() => (drawerOpen = true)}>
  <BellIcon size={20} />
  Notifications
  {#if unreadCount > 0}
    <Badge variant="soft" intent="danger" size="sm">{unreadCount}</Badge>
  {/if}
</Button>

<Drawer bind:open={drawerOpen} title="Notifications" placement="right" size="md">
  <Tab bind:value={activeTab} variant="line" size="sm">
    {#snippet tabs()}
      <TabItem value="all">All</TabItem>
      <TabItem value="unread">
        Unread
        {#if unreadCount > 0}
          <Badge variant="soft" intent="danger" size="sm">{unreadCount}</Badge>
        {/if}
      </TabItem>
    {/snippet}

    {#snippet panels()}
      <TabPanel value="all">{@render list(notifications)}</TabPanel>
      <TabPanel value="unread">{@render list(unreadNotifications)}</TabPanel>
    {/snippet}
  </Tab>

  {#snippet footer()}
    <div class="flex items-center justify-between">
      <Button
        size="sm"
        variant="ghost"
        intent="neutral"
        onclick={markAllAsRead}
        disabled={unreadCount === 0}
      >
        Mark all as read
      </Button>
      <p class="text-text-quaternary text-xs">{notifications.length} total</p>
    </div>
  {/snippet}
</Drawer>

Three decisions

A drawer, not a popover under the bell

A Popover anchored to the bell suits a short preview. This panel carries tabs, per-item actions and a footer, so it takes a full-height column instead: Drawer mounts in the top layer, traps focus and locks page scroll, and Escape, backdrop click and the close button all dismiss it. bind:open is the whole wiring; the drawer sets it back to false on each of those paths, which is why the recipe passes no onClose.

The tabs are filters, not lists

notifications is the only state. unreadCount and unreadNotifications are $derived from it, so the badge on the bell, the badge on the tab and the footer's disabled state cannot disagree, and marking an item read drops it out of the Unread panel with no list bookkeeping. Both panels render the same list snippet, and the empty state lives inside it: an emptied filter shows "All caught up" in either tab.

Read dims, archive removes

The two row actions differ in lifetime. Mark as read flips a flag: the item stays in the All tab, dimmed, as the history. Archive drops it from the array, and that is the recipe's whole deletion story: no undo, no archive folder. When your product needs archived items back, have archiveNotification move them to a second list instead of filtering them out.

Everything here lives in one $state array and resets on reload. When notifications come from a server, @urbicon-ui/auth ships the wired version: NotificationCenter is this list with mark-as-read and delete against your endpoints, NotificationBadge the bell count that renders nothing at zero, and NotificationListener the live stream behind both.