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.
Click the bell to open the panel · Click a notification to mark read · Mark all read clears the badge
// 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
Command Palette
Linear
Global Search with Results Grouping
Linear
Recent + Suggested Search
Raycast
Filter Builder (AND / OR)
Linear
Sidebar Navigation
Linear
Collapsible Nested Tree Nav
Notion
Tab Bar with Overflow Menu
Vercel
Breadcrumb with Dropdowns
Linear
Workspace Switcher
Slack
Deployment Status Card
Vercel
Empty State
Vercel
Error State with Retry
Notion
Slash Command Menu
Notion
Stats & Metrics Row
Vercel
Single Big Metric Card
Stripe
Usage / Quota Meter
Vercel
Skeleton Loading Grid
Linear
Activity Heatmap
Notion
Sparkline Trend Cards
Linear
Confirmation Dialog
Linear
Slide-Over Panel
Stripe
Toast / Snackbar Stack
Vercel
Notification Center
Linear
Issue / Task Card
Linear
Activity Feed
Linear + Slack
Comment Thread
Linear
Audit Log Timeline
Vercel
Properties Panel
Notion + Linear
Multi-Step Form Wizard
Stripe
Tag Multi-Select
Linear
Toggle Switch Group
Stripe
File Upload Dropzone
Vercel
Inline Data Table
Notion + Linear
Pagination
Vercel
Sortable / Selectable Table
Linear
Row Detail Drawer
Stripe
Status Badge System
Linear
Segmented Control
Notion