"use client";
import * as React from "react";
import { AnimatePresence, motion, useAnimate } from "motion/react";
function subscribeReducedMotion(callback: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
}
/** SSR-safe: hydrates as "no preference", then follows the media query. */
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
export interface BellNotification {
id: string;
title: string;
body?: string;
/** Pre-formatted time label, e.g. "2m ago" (kept as text so server and client agree). */
time?: string;
read?: boolean;
}
export interface NotificationBellProps extends Omit<React.ComponentPropsWithoutRef<"div">, "children"> {
notifications: BellNotification[];
/** Called when a notification is activated in the panel. */
onMarkRead?: (id: string) => void;
/** Called by the "Mark all as read" button. */
onMarkAllRead?: () => void;
onOpenChange?: (open: boolean) => void;
/** "count" shows a number badge; "dot" shows a small indicator only. */
variant?: "count" | "dot";
/** Counts above this show as "{max}+". */
max?: number;
/** Wobble the bell when the unread count goes up (never on mount). */
ring?: boolean;
/** Which edge of the bell the panel lines up with. */
align?: "start" | "end";
/** Badge and unread-marker colour (any CSS colour). */
accent?: string;
/** Panel heading and the base of the button's accessible name. */
label?: string;
emptyMessage?: string;
}
const RING_KEYFRAMES = [0, 16, -14, 10, -7, 4, -2, 0];
/**
* A bell button with an unread badge that rolls between counts (capped at "{max}+") and a
* wobble when new notifications arrive. It opens a native popover panel (top layer, light
* dismiss, Escape) exposed as a dialog, moves focus into it and returns focus to the bell.
*/
export function NotificationBell({
notifications,
onMarkRead,
onMarkAllRead,
onOpenChange,
variant = "count",
max = 99,
ring = true,
align = "end",
accent = "#FF7448",
label = "Notifications",
emptyMessage = "You’re all caught up.",
className = "",
style,
...props
}: NotificationBellProps) {
const reduceMotion = usePrefersReducedMotion();
const uid = React.useId();
const panelId = `nb-${uid}-panel`;
const headingId = `nb-${uid}-heading`;
const [open, setOpen] = React.useState(false);
const [bellScope, animate] = useAnimate<SVGSVGElement>();
const buttonRef = React.useRef<HTMLButtonElement>(null);
const panelRef = React.useRef<HTMLDivElement>(null);
const unread = notifications.filter((n) => !n.read).length;
const display = unread > max ? `${max}+` : String(unread);
// Track count changes during render ("adjust state when a prop changes"): direction for the
// rolling digits and a ring counter that only increments on increases.
const [prevUnread, setPrevUnread] = React.useState(unread);
const [direction, setDirection] = React.useState<1 | -1>(1);
const [rings, setRings] = React.useState(0);
if (unread !== prevUnread) {
setDirection(unread > prevUnread ? 1 : -1);
if (unread > prevUnread) setRings((r) => r + 1);
setPrevUnread(unread);
}
React.useEffect(() => {
if (rings === 0 || !ring || reduceMotion || !bellScope.current) return;
const controls = animate(bellScope.current, { rotate: RING_KEYFRAMES }, { duration: 0.85, ease: "easeInOut" });
return () => controls.stop();
}, [rings, ring, reduceMotion, animate, bellScope]);
/* --------------------------------- popover -------------------------------- */
const position = React.useCallback(() => {
const panel = panelRef.current;
const button = buttonRef.current;
if (!panel || !button) return;
const rect = button.getBoundingClientRect();
const width = panel.offsetWidth;
const height = panel.offsetHeight;
const below = rect.bottom + 8;
const top = below + height > window.innerHeight - 8 && rect.top - height - 8 > 8 ? rect.top - height - 8 : below;
const preferred = align === "end" ? rect.right - width : rect.left;
const left = Math.min(Math.max(preferred, 8), Math.max(window.innerWidth - width - 8, 8));
panel.style.top = `${Math.round(top)}px`;
panel.style.left = `${Math.round(left)}px`;
}, [align]);
const reportOpen = React.useEffectEvent((next: boolean) => onOpenChange?.(next));
React.useEffect(() => {
const panel = panelRef.current;
if (!panel) return;
const onToggle = (event: Event) => {
const next = (event as ToggleEvent).newState === "open";
setOpen(next);
reportOpen(next);
if (next) {
position();
// Move focus into the dialog: its first control, or the panel itself.
requestAnimationFrame(() => {
const first = panel.querySelector<HTMLElement>("button:not([disabled]), [href]");
(first ?? panel).focus({ preventScroll: true });
});
} else if (panel.contains(document.activeElement) || document.activeElement === document.body) {
buttonRef.current?.focus({ preventScroll: true });
}
};
panel.addEventListener("toggle", onToggle);
return () => panel.removeEventListener("toggle", onToggle);
}, [position]);
// Keep the panel attached to the bell while open.
React.useEffect(() => {
if (!open) return;
position(); // the panel has rendered its content by now, so measure again
window.addEventListener("scroll", position, { capture: true, passive: true });
window.addEventListener("resize", position);
return () => {
window.removeEventListener("scroll", position, { capture: true });
window.removeEventListener("resize", position);
};
}, [open, position]);
const buttonLabel = `${label}, ${unread === 0 ? "no unread" : `${unread} unread`}`;
const spring = { type: "spring" as const, stiffness: 520, damping: 30, mass: 0.6 };
return (
<div className={`relative inline-flex ${className}`} style={{ ["--nb-accent" as string]: accent, ...style }} {...props}>
<button
ref={buttonRef}
type="button"
popoverTarget={panelId}
aria-label={buttonLabel}
aria-haspopup="dialog"
aria-expanded={open}
aria-controls={panelId}
className={`relative grid size-11 place-items-center rounded-xl border text-zinc-700 outline-hidden transition-colors duration-150 hover:text-zinc-950 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--nb-accent)] dark:text-zinc-300 dark:hover:text-white motion-reduce:transition-none ${
open
? "border-zinc-900/20 bg-zinc-900/[0.06] dark:border-white/20 dark:bg-white/[0.08]"
: "border-zinc-900/10 bg-white hover:bg-zinc-50 dark:border-white/10 dark:bg-zinc-950 dark:hover:bg-zinc-900"
}`}
>
<svg
ref={bellScope}
aria-hidden="true"
viewBox="0 0 20 20"
className="size-5 origin-[50%_12%]"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M5 8a5 5 0 0 1 10 0c0 4.2 1.6 5.6 1.6 5.6H3.4S5 12.2 5 8z" />
<path d="M8.3 16.4a1.9 1.9 0 0 0 3.4 0" />
</svg>
<AnimatePresence initial={false}>
{unread > 0 &&
(variant === "dot" ? (
<motion.span
key="dot"
aria-hidden="true"
initial={reduceMotion ? { opacity: 0 } : { scale: 0 }}
animate={reduceMotion ? { opacity: 1 } : { scale: 1 }}
exit={reduceMotion ? { opacity: 0 } : { scale: 0 }}
transition={reduceMotion ? { duration: 0 } : spring}
className="absolute right-2.5 top-2.5 size-2.5 rounded-full bg-[var(--nb-accent)] ring-2 ring-white dark:ring-zinc-950"
/>
) : (
<motion.span
key="count"
aria-hidden="true"
initial={reduceMotion ? { opacity: 0 } : { scale: 0 }}
animate={reduceMotion ? { opacity: 1 } : { scale: 1 }}
exit={reduceMotion ? { opacity: 0 } : { scale: 0 }}
transition={reduceMotion ? { duration: 0 } : spring}
className="absolute -right-1.5 -top-1.5 flex h-5 min-w-5 items-center justify-center overflow-hidden rounded-full bg-[var(--nb-accent)] px-1.5 font-mono text-[10px] font-semibold leading-none tabular-nums text-white ring-2 ring-white dark:text-zinc-950 dark:ring-zinc-950"
>
{/* Rolling digits: the new count enters from below (up) or above (down). */}
<AnimatePresence initial={false} mode="popLayout" custom={direction}>
<motion.span
key={display}
custom={direction}
variants={{
enter: (d: number) => (reduceMotion ? { opacity: 0 } : { y: d * 12, opacity: 0 }),
center: { y: 0, opacity: 1 },
exit: (d: number) => (reduceMotion ? { opacity: 0 } : { y: d * -12, opacity: 0 }),
}}
initial="enter"
animate="center"
exit="exit"
transition={reduceMotion ? { duration: 0 } : { type: "spring", stiffness: 600, damping: 34 }}
className="block"
>
{display}
</motion.span>
</AnimatePresence>
</motion.span>
))}
</AnimatePresence>
</button>
<div
ref={panelRef}
id={panelId}
popover="auto"
role="dialog"
aria-labelledby={headingId}
tabIndex={-1}
className="fixed inset-auto m-0 w-[min(22rem,calc(100vw-16px))] overflow-visible bg-transparent p-0 text-zinc-900 outline-hidden dark:text-zinc-100"
style={{ ["--nb-accent" as string]: accent }}
>
{open && (
<motion.div
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: -6, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={reduceMotion ? { duration: 0 } : { type: "spring", stiffness: 520, damping: 36, mass: 0.7 }}
style={{ transformOrigin: align === "end" ? "top right" : "top left" }}
className="overflow-hidden rounded-2xl border border-zinc-900/10 bg-white shadow-[0_24px_48px_-24px_rgb(0_0_0/0.4)] dark:border-white/10 dark:bg-zinc-900"
>
<div className="flex items-center justify-between gap-3 border-b border-zinc-900/[0.07] px-4 py-3 dark:border-white/[0.07]">
<h2 id={headingId} className="flex items-baseline gap-2 text-[14px] font-semibold tracking-[-0.01em]">
{label}
<span className="font-mono text-[10px] font-normal uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400">
{unread} unread
</span>
</h2>
<button
type="button"
disabled={unread === 0}
onClick={() => onMarkAllRead?.()}
className="rounded-md px-1.5 py-1 text-[12px] font-medium text-zinc-600 outline-hidden transition-colors hover:text-zinc-950 focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-[var(--nb-accent)] disabled:cursor-default disabled:opacity-40 dark:text-zinc-400 dark:hover:text-white"
>
Mark all as read
</button>
</div>
{notifications.length === 0 ? (
<div className="flex flex-col items-center gap-2 px-6 py-10 text-center">
<svg aria-hidden="true" viewBox="0 0 20 20" className="size-6 text-zinc-300 dark:text-zinc-600" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round">
<path d="M5 8a5 5 0 0 1 10 0c0 4.2 1.6 5.6 1.6 5.6H3.4S5 12.2 5 8z" />
<path d="M8.3 16.4a1.9 1.9 0 0 0 3.4 0" />
</svg>
<p className="text-[13px] text-zinc-500 dark:text-zinc-400">{emptyMessage}</p>
</div>
) : (
<ul aria-label={label} className="max-h-[min(22rem,60vh)] overflow-y-auto overscroll-contain p-1.5">
<AnimatePresence initial={false}>
{notifications.map((n) => (
<motion.li
key={n.id}
layout={reduceMotion ? false : "position"}
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, height: 0 }}
animate={reduceMotion ? { opacity: 1 } : { opacity: 1, height: "auto" }}
exit={{ opacity: 0 }}
transition={reduceMotion ? { duration: 0 } : { duration: 0.22, ease: [0.2, 0.7, 0.2, 1] }}
className="overflow-hidden"
>
<button
type="button"
onClick={() => {
if (!n.read) onMarkRead?.(n.id);
}}
className="group/nb flex w-full items-start gap-3 rounded-xl px-2.5 py-2.5 text-left outline-hidden transition-colors hover:bg-zinc-900/[0.04] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--nb-accent)] dark:hover:bg-white/[0.05]"
>
<span aria-hidden="true" className="mt-1.5 flex size-2 shrink-0 items-center justify-center">
<span
className={`size-2 rounded-full transition-[transform,opacity] duration-200 motion-reduce:transition-none ${
n.read ? "scale-50 opacity-0" : "bg-[var(--nb-accent)]"
}`}
/>
</span>
<span className="min-w-0 flex-1">
<span className="flex items-baseline justify-between gap-3">
<span
className={`truncate text-[13px] ${n.read ? "text-zinc-600 dark:text-zinc-400" : "font-semibold text-zinc-950 dark:text-white"}`}
>
<span className="sr-only">{n.read ? "" : "Unread: "}</span>
{n.title}
</span>
{n.time && (
<span className="shrink-0 font-mono text-[10px] uppercase tracking-[0.1em] text-zinc-400 dark:text-zinc-500">{n.time}</span>
)}
</span>
{n.body && (
<span className="mt-0.5 line-clamp-2 block text-[12px] leading-snug text-zinc-500 dark:text-zinc-400">{n.body}</span>
)}
</span>
</button>
</motion.li>
))}
</AnimatePresence>
</ul>
)}
</motion.div>
)}
</div>
{/* Announce arrivals politely; the button name carries the current count. */}
<span className="sr-only" aria-live="polite" aria-atomic="true">
{rings > 0 && unread > 0 && direction === 1 ? `${unread} unread ${unread === 1 ? "notification" : "notifications"}` : ""}
</span>
</div>
);
}