Notification Center in React: Bell Icon, Unread Badge, Popover List
Build a full notification center in React - bell icon, animated unread badge, keyboard-accessible popover list, and real-time updates. Code included.
What You're Actually Building Here
A notification center sounds like a small feature. It isn't. By the time you account for the bell icon, the unread count badge, the popover panel, mark-as-read logic, empty states, and keyboard accessibility - you've got five or six moving parts that all need to stay in sync. Most tutorials punt on the hard parts. This one doesn't.
The final component we're building has three layers: a BellButton that shows a badge when there are unread items, a NotificationPopover that opens on click and traps focus correctly, and a NotificationList that renders individual items with read/unread state. You'd wire these up through a lightweight useNotifications hook that holds the state and exposes the actions your app needs.
Worth noting: we're not reaching for a library to do this. React 18's useId, useTransition, and standard DOM events give you everything you need. That said, if you want to style the popover with something more dimensional - say, a glassmorphism surface - Empire UI's glassmorphism components drop in cleanly here. More on that later.
One more thing - this architecture scales. Whether you've got 5 notifications or 500, the same hook handles it. We'll cover pagination at the end.
The Bell Icon and Unread Badge
The badge is the trickiest part visually because it needs to sit on top of the icon with a specific offset, not reflow the surrounding layout. The right way to do this in 2026 is position: relative on the button wrapper and position: absolute on the badge. No magic numbers - just a top-0 right-0 translate-x-1/2 -translate-y-1/2 combo in Tailwind.
// BellButton.tsx
import { BellIcon } from 'lucide-react';
interface BellButtonProps {
unreadCount: number;
onClick: () => void;
isOpen: boolean;
}
export function BellButton({ unreadCount, onClick, isOpen }: BellButtonProps) {
const hasUnread = unreadCount > 0;
return (
<button
onClick={onClick}
aria-label={hasUnread ? `${unreadCount} unread notifications` : 'Notifications'}
aria-expanded={isOpen}
aria-haspopup="true"
className="relative p-2 rounded-full hover:bg-white/10 transition-colors"
>
<BellIcon
size={22}
className={isOpen ? 'text-violet-400' : 'text-gray-300'}
/>
{hasUnread && (
<span
aria-hidden="true"
className="
absolute top-0 right-0
translate-x-1/2 -translate-y-1/2
min-w-[18px] h-[18px] px-1
flex items-center justify-center
text-[10px] font-bold text-white
bg-red-500 rounded-full
ring-2 ring-gray-900
"
>
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
</button>
);
}Honestly, the aria-label with the count is what most implementations skip, and it's the part that actually matters for screen-reader users. The visual badge is aria-hidden="true" because the label on the button itself already communicates the count - doubling it would be noisy.
The ring-2 ring-gray-900 on the badge creates a 2px gap between the red dot and the icon behind it. It's a small detail but it's what separates components that look designed from components that look built. Adjust the ring color to match your page background.
The useNotifications Hook
Before building the UI, nail the state. Everything - open/close, read state, list data - lives in one hook. This keeps your components dumb and testable.
// useNotifications.ts
import { useState, useCallback } from 'react';
export interface Notification {
id: string;
title: string;
body: string;
timestamp: Date;
read: boolean;
href?: string;
}
export function useNotifications(initial: Notification[] = []) {
const [notifications, setNotifications] = useState<Notification[]>(initial);
const [isOpen, setIsOpen] = useState(false);
const unreadCount = notifications.filter((n) => !n.read).length;
const markAsRead = useCallback((id: string) => {
setNotifications((prev) =>
prev.map((n) => (n.id === id ? { ...n, read: true } : n))
);
}, []);
const markAllAsRead = useCallback(() => {
setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
}, []);
const dismiss = useCallback((id: string) => {
setNotifications((prev) => prev.filter((n) => n.id !== id));
}, []);
const toggle = useCallback(() => setIsOpen((o) => !o), []);
const close = useCallback(() => setIsOpen(false), []);
return {
notifications,
unreadCount,
isOpen,
toggle,
close,
markAsRead,
markAllAsRead,
dismiss,
};
}In practice, you'd replace that useState(initial) with a useQuery from React Query or SWR fetching from your backend. The hook interface stays identical - only the data source changes. That's the whole point of this abstraction.
Quick aside: if you're building this on a Next.js 14+ app-router project, the hook itself is fine in a Client Component but don't try to put it in a Server Component. Notifications are inherently interactive state. Check out the nextjs-app-router-guide if you're fuzzy on where the boundary sits.
The Popover - Opening, Positioning, and Closing
Popovers are where most implementations break. The three classic bugs: clicking outside doesn't close it, pressing Escape doesn't close it, and Tab key wanders out of the panel while it's open. All three are fixable with about 20 lines of vanilla React.
// NotificationPopover.tsx
import { useEffect, useRef } from 'react';
import type { Notification } from './useNotifications';
import { NotificationList } from './NotificationList';
interface NotificationPopoverProps {
isOpen: boolean;
notifications: Notification[];
onClose: () => void;
onMarkAsRead: (id: string) => void;
onMarkAllAsRead: () => void;
onDismiss: (id: string) => void;
}
export function NotificationPopover({
isOpen,
notifications,
onClose,
onMarkAsRead,
onMarkAllAsRead,
onDismiss,
}: NotificationPopoverProps) {
const panelRef = useRef<HTMLDivElement>(null);
// Close on outside click
useEffect(() => {
if (!isOpen) return;
function handleClick(e: MouseEvent) {
if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
onClose();
}
}
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [isOpen, onClose]);
// Close on Escape
useEffect(() => {
if (!isOpen) return;
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') onClose();
}
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<div
ref={panelRef}
role="dialog"
aria-label="Notifications"
aria-modal="false"
className="
absolute right-0 top-full mt-2 z-50
w-[380px] max-h-[480px]
overflow-hidden flex flex-col
rounded-2xl border border-white/10
bg-gray-900/80 backdrop-blur-xl
shadow-2xl shadow-black/40
"
>
<header className="flex items-center justify-between px-4 py-3 border-b border-white/10">
<h2 className="text-sm font-semibold text-white">Notifications</h2>
<button
onClick={onMarkAllAsRead}
className="text-xs text-violet-400 hover:text-violet-300 transition-colors"
>
Mark all read
</button>
</header>
<NotificationList
notifications={notifications}
onMarkAsRead={onMarkAsRead}
onDismiss={onDismiss}
/>
</div>
);
}The backdrop-blur-xl on the panel is that glassmorphism treatment I mentioned earlier. Over a dark background it looks sharp; over a lighter page you'd swap bg-gray-900/80 for bg-white/70. The glassmorphism generator will spit out the exact values for your palette in about 30 seconds.
Look, aria-modal="false" is intentional. This is a non-modal popover - the rest of the page is still interactive. Screen readers should be free to leave the panel. If you change it to a true modal (blocking interaction) you also need a focus trap, which is a different and larger solution.
The w-[380px] is hardcoded because notification centers shouldn't be fluid - they have a known comfortable reading width. On small screens you'd add a max-w-[calc(100vw-32px)] to prevent overflow.
The Notification List and Individual Items
Each notification item needs to communicate its read state visually, provide a click target for marking it read, and have a dismiss button that doesn't accidentally trigger the item's main action. That's three overlapping interaction zones in about 64px of vertical space.
// NotificationList.tsx
import type { Notification } from './useNotifications';
import { XIcon } from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
interface NotificationListProps {
notifications: Notification[];
onMarkAsRead: (id: string) => void;
onDismiss: (id: string) => void;
}
export function NotificationList({ notifications, onMarkAsRead, onDismiss }: NotificationListProps) {
if (notifications.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-16 text-center">
<span className="text-3xl mb-3">🔔</span>
<p className="text-sm text-gray-400">You're all caught up</p>
</div>
);
}
return (
<ul className="overflow-y-auto flex-1 divide-y divide-white/5">
{notifications.map((n) => (
<li
key={n.id}
className={`
group relative flex gap-3 px-4 py-3
transition-colors cursor-pointer
${n.read ? 'bg-transparent' : 'bg-violet-500/5'}
hover:bg-white/5
`}
onClick={() => onMarkAsRead(n.id)}
>
{/* Unread dot */}
{!n.read && (
<span className="absolute left-1.5 top-1/2 -translate-y-1/2 w-1.5 h-1.5 rounded-full bg-violet-400" />
)}
<div className="flex-1 min-w-0 pl-2">
<p className={`text-sm ${n.read ? 'text-gray-400' : 'text-white font-medium'}`}>
{n.title}
</p>
<p className="text-xs text-gray-500 mt-0.5 truncate">{n.body}</p>
<p className="text-[10px] text-gray-600 mt-1">
{formatDistanceToNow(n.timestamp, { addSuffix: true })}
</p>
</div>
{/* Dismiss button - stop propagation so it doesn't mark-as-read */}
<button
aria-label={`Dismiss: ${n.title}`}
onClick={(e) => { e.stopPropagation(); onDismiss(n.id); }}
className="
self-start mt-1 p-0.5 rounded
opacity-0 group-hover:opacity-100 focus:opacity-100
text-gray-500 hover:text-white
transition-opacity
"
>
<XIcon size={14} />
</button>
</li>
))}
</ul>
);
}The e.stopPropagation() on the dismiss button is the key move. Without it, dismissing a notification also marks it as read - which doesn't matter because it's gone, but it's sloppy behavior and breaks if you ever add analytics events to both actions.
That opacity-0 group-hover:opacity-100 focus:opacity-100 pattern on the dismiss button is worth stealing for any table or list where you want actions visible on hover but not cluttering the default view. The focus:opacity-100 part is what makes it keyboard-accessible - keyboard users never hover, so hover-only visibility is an accessibility bug.
That said, date-fns is the only external dependency in this whole setup. If you're already using it (and you probably are in 2026), no additional install needed. If you're not, formatDistanceToNow is easy to inline for the basic cases.
Wiring It All Together and Adding Real-Time Updates
Here's the full parent component that composes everything. This is what you'd actually drop into your nav header.
// NotificationCenter.tsx
import { useRef } from 'react';
import { useNotifications } from './useNotifications';
import { BellButton } from './BellButton';
import { NotificationPopover } from './NotificationPopover';
const MOCK_NOTIFICATIONS = [
{
id: '1',
title: 'New comment on your post',
body: 'Alex replied: "This is exactly what I needed."',
timestamp: new Date(Date.now() - 1000 * 60 * 3),
read: false,
},
{
id: '2',
title: 'Deployment succeeded',
body: 'Production build #247 deployed in 38s',
timestamp: new Date(Date.now() - 1000 * 60 * 47),
read: false,
},
{
id: '3',
title: 'Your trial ends in 3 days',
body: 'Upgrade to Pro to keep your components.',
timestamp: new Date(Date.now() - 1000 * 60 * 60 * 5),
read: true,
},
];
export function NotificationCenter() {
const wrapperRef = useRef<HTMLDivElement>(null);
const nc = useNotifications(MOCK_NOTIFICATIONS);
return (
<div ref={wrapperRef} className="relative">
<BellButton
unreadCount={nc.unreadCount}
onClick={nc.toggle}
isOpen={nc.isOpen}
/>
<NotificationPopover
isOpen={nc.isOpen}
notifications={nc.notifications}
onClose={nc.close}
onMarkAsRead={nc.markAsRead}
onMarkAllAsRead={nc.markAllAsRead}
onDismiss={nc.dismiss}
/>
</div>
);
}For real-time updates, plug a WebSocket or SSE stream into the hook. Add a addNotification action that does setNotifications(prev => [newItem, ...prev]). Your WebSocket handler calls that function. The UI reacts automatically - no polling, no refresh.
If you want the badge to animate when a new notification arrives, add a brief CSS scale animation triggered by a key change. Something like: wrap the badge span in a component that takes unreadCount as key, and it'll remount (and therefore replay its entry animation) every time the count changes. Simple and effective.
One more thing - if you want to style this whole thing to match your design system rather than the dark glassmorphism defaults shown here, browse components to see how Empire UI's style tokens work. Everything from neumorphism to neobrutalism has its own surface and shadow system you can apply to these panels directly.
Edge Cases Worth Your Time
The empty state matters more than you think. Users actively look at an empty notification panel to confirm there's nothing new. Don't just render nothing - render a clear message. The emoji + short text pattern we used (🔔 You're all caught up) is enough.
Consider what happens when notifications come in while the popover is already open. You've got two choices: silently prepend them to the list (good), or show a 'New notifications' banner at the top with a refresh button (better for high-volume apps). The silent prepend can be jarring if the list shifts under the user's cursor mid-scroll.
What about 99+ notifications? The badge caps at 99+ in our code, but your list might have 200 items. Add a 'Load more' button or virtual scrolling at that point - max-h-[480px] with overflow-y-auto on the list handles the visible case, but rendering 200 DOM nodes upfront is wasteful. React Virtual or @tanstack/react-virtual handles this if you ever hit that scale.
Honestly, the role="dialog" with aria-modal="false" setup we chose here is the right call for this pattern. But if you want to get deeper into ARIA patterns for this kind of overlay - and you should, especially if your users are on enterprise software - the W3C APG has a disclosure navigation pattern that's worth reading before you ship to production.
FAQ
No. The hook + DOM event pattern described here covers everything. Reach for Radix UI's Popover or Floating UI only if you need complex positioning logic like sub-menus or smart collision detection.
Store read notification IDs in localStorage or sync them to your backend. In the hook, initialize state by merging your fetched notifications with the stored read IDs before setting state.
Yes - wrap the panel in <AnimatePresence> and add initial, animate, and exit props. A simple opacity + scale from 0.95 to 1 with a 0.15s ease-out feels right for this component.
Use role="dialog" with aria-modal="false" for a non-blocking popover, or omit the role entirely and just use aria-live regions if you want screen readers to announce new notifications automatically.
