Notification Center

A bell-triggered notification panel with an unread count badge, All / Unread tabs, and a list of notifications with avatar initials, title, body, timestamp, and unread dot. Click any notification to mark it read; Mark all read clears the badge. Closes on outside click.

NotificationsInspired by Linear
Live PreviewInteractive
Linear · Inbox

Click the bell to open the panel · Click a notification to mark read · Mark all read clears the badge

NotificationCenter.tsx
// Dependencies: react ^18, lucide-react
import React, { useState, useRef, useEffect } from 'react';
import { Bell, X, Check } from 'lucide-react';

function cn(...classes: (string | false | null | undefined)[]) {
  return classes.filter(Boolean).join(' ');
}

export type Notification = {
  id: string;
  initials: string;
  color: string;
  title: string;
  body: string;
  time: string;
  read: boolean;
};

type Props = {
  initialNotifications: Notification[];
};

export function NotificationCenter({ initialNotifications }: Props) {
  const [open, setOpen] = useState(false);
  const [notifications, setNotifications] = useState<Notification[]>(initialNotifications);
  const [tab, setTab] = useState<'all' | 'unread'>('all');
  const panelRef = useRef<HTMLDivElement>(null);

  const unreadCount = notifications.filter((n) => !n.read).length;
  const displayed = tab === 'unread' ? notifications.filter((n) => !n.read) : notifications;

  useEffect(() => {
    if (!open) return;
    const handle = (e: MouseEvent) => {
      if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
        setOpen(false);
      }
    };
    document.addEventListener('mousedown', handle);
    return () => document.removeEventListener('mousedown', handle);
  }, [open]);

  const markRead = (id: string) =>
    setNotifications((prev) => prev.map((n) => (n.id === id ? { ...n, read: true } : n)));

  const markAllRead = () =>
    setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));

  return (
    <div className="relative" ref={panelRef}>
      <button
        onClick={() => setOpen((o) => !o)}
        className={cn(
          'relative w-8 h-8 rounded-lg flex items-center justify-center transition-colors',
          open ? 'bg-zinc-800 text-white' : 'text-zinc-500 hover:bg-zinc-800/60 hover:text-zinc-300',
        )}
        aria-label={`Notifications${unreadCount > 0 ? `, ${unreadCount} unread` : ''}`}
      >
        <Bell className="w-4 h-4" />
        {unreadCount > 0 && (
          <span className="absolute -top-0.5 -right-0.5 min-w-[16px] h-4 rounded-full bg-indigo-500 flex items-center justify-center px-1 text-[10px] font-bold text-white leading-none">
            {unreadCount > 9 ? '9+' : unreadCount}
          </span>
        )}
      </button>

      {open && (
        <div className="absolute right-0 top-full mt-2 w-80 bg-[#1a1a1a] border border-[#2a2a2a] rounded-lg shadow-2xl z-50 flex flex-col overflow-hidden">
          {/* Header */}
          <div className="flex items-center justify-between px-4 py-3 border-b border-[#2a2a2a]">
            <span className="text-sm font-semibold text-white">Notifications</span>
            <div className="flex items-center gap-2">
              {unreadCount > 0 && (
                <button
                  onClick={markAllRead}
                  className="flex items-center gap-1 text-[11px] text-indigo-400 hover:text-indigo-300 transition-colors"
                >
                  <Check className="w-3 h-3" />
                  Mark all read
                </button>
              )}
              <button onClick={() => setOpen(false)} className="text-zinc-600 hover:text-zinc-400 transition-colors">
                <X className="w-3.5 h-3.5" />
              </button>
            </div>
          </div>

          {/* Tabs */}
          <div className="flex border-b border-[#2a2a2a]">
            {(['all', 'unread'] as const).map((t) => (
              <button
                key={t}
                onClick={() => setTab(t)}
                className={cn(
                  'flex-1 py-2 text-xs font-medium capitalize transition-colors',
                  tab === t ? 'text-white border-b-2 border-indigo-500 -mb-px' : 'text-zinc-500 hover:text-zinc-300',
                )}
              >
                {t === 'all' ? 'All' : `Unread${unreadCount > 0 ? ` (${unreadCount})` : ''}`}
              </button>
            ))}
          </div>

          {/* Notification list */}
          <div className="flex flex-col max-h-[320px] overflow-y-auto">
            {displayed.length === 0 ? (
              <div className="flex flex-col items-center justify-center py-8 gap-2">
                <Bell className="w-6 h-6 text-zinc-700" />
                <p className="text-xs text-zinc-600">No {tab === 'unread' ? 'unread ' : ''}notifications</p>
              </div>
            ) : (
              displayed.map((n) => (
                <button
                  key={n.id}
                  onClick={() => markRead(n.id)}
                  className={cn(
                    'flex items-start gap-3 px-4 py-3 text-left transition-colors border-b border-[#2a2a2a] last:border-0',
                    n.read ? 'hover:bg-white/[0.03]' : 'bg-indigo-500/[0.06] hover:bg-indigo-500/[0.10]',
                  )}
                >
                  <div
                    className="w-8 h-8 rounded-full flex items-center justify-center text-[11px] font-bold text-white shrink-0 mt-0.5"
                    style={{ backgroundColor: n.color }}
                  >
                    {n.initials}
                  </div>
                  <div className="flex-1 min-w-0">
                    <p className={cn('text-[12px] font-medium leading-snug', n.read ? 'text-zinc-400' : 'text-white')}>
                      {n.title}
                    </p>
                    <p className="text-[11px] text-zinc-500 mt-0.5 leading-snug line-clamp-2">{n.body}</p>
                    <p className="text-[10px] text-zinc-600 mt-1">{n.time}</p>
                  </div>
                  {!n.read && (
                    <span className="w-1.5 h-1.5 rounded-full bg-indigo-400 shrink-0 mt-1.5" />
                  )}
                </button>
              ))
            )}
          </div>
        </div>
      )}
    </div>
  );
}

// Usage:
// const NOTIFICATIONS: Notification[] = [
//   { id: '1', initials: 'SC', color: '#6366f1', title: 'Sarah Chen mentioned you', body: 'Can you review the latest designs?', time: '2 min ago', read: false },
//   { id: '2', initials: 'MK', color: '#10b981', title: 'Deploy succeeded', body: 'Production deploy completed in 1m 22s.', time: '1 hr ago', read: true },
// ];
// <NotificationCenter initialNotifications={NOTIFICATIONS} />

Unlock to copy

Free access to all patterns