Bell button whose badge pops in with a bounce, rolls count changes vertically, and swings the bell once on new arrivals.
npx shadcn@latest add @paragon/notification-bell"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Bell } from "lucide-react";
import { cn } from "@/lib/utils";
export interface NotificationBellProps extends React.ComponentProps<"button"> {
/** Unread count. 0 hides the badge. */
count?: number;
/** Cap for the displayed number; beyond it shows e.g. "99+". */
maxDisplay?: number;
/** Disables the bell swing on new arrivals. */
static?: boolean;
}
/** Matches --ease-bounce for motion/react transitions. */
const easeBounce = [0.34, 1.36, 0.64, 1] as const;
/**
* Bell icon button with an unread badge. The badge pops in with a bounce,
* count changes roll vertically (old digit up, new digit in from below),
* and the bell swings ±12° once when the count rises. Reduced motion keeps
* the badge update only.
*/
export function NotificationBell({
count = 0,
maxDisplay = 99,
static: isStatic = false,
className,
...props
}: NotificationBellProps) {
const reduced = useReducedMotion();
const prev = React.useRef(count);
const [swingKey, setSwingKey] = React.useState(0);
React.useEffect(() => {
if (count > prev.current) setSwingKey((k) => k + 1);
prev.current = count;
}, [count]);
const display = count > maxDisplay ? `${maxDisplay}+` : `${count}`;
return (
<button
type="button"
aria-label={
count > 0
? `Notifications, ${count} unread`
: "Notifications"
}
className={cn(
"pressable relative flex size-9 items-center justify-center rounded-lg text-muted-foreground transition-colors duration-150 hover:bg-accent hover:text-accent-foreground after:absolute after:top-1/2 after:left-1/2 after:size-10 after:-translate-1/2",
className,
)}
{...props}
>
<style href="paragon-notification-bell" precedence="paragon">{`
@keyframes pg-bell-swing {
0% { transform: rotate(0deg); }
30% { transform: rotate(-12deg); }
65% { transform: rotate(12deg); }
100% { transform: rotate(0deg); }
}
[data-bell-swing] {
animation: pg-bell-swing 300ms var(--ease-in-out) 1;
transform-origin: 50% 2px;
}
@media (prefers-reduced-motion: reduce) {
[data-bell-swing] { animation: none; }
}
`}</style>
<span
key={swingKey}
data-bell-swing={swingKey > 0 && !isStatic ? "" : undefined}
className="flex"
aria-hidden
>
<Bell className="size-4" />
</span>
<AnimatePresence initial={false}>
{count > 0 && (
<motion.span
aria-hidden
initial={
reduced ? { opacity: 0 } : { opacity: 0, scale: 0.5 }
}
animate={{ opacity: 1, scale: 1 }}
exit={
reduced
? { opacity: 0 }
: { opacity: 0, scale: 0.8, transition: { duration: 0.1 } }
}
transition={{ duration: 0.25, ease: easeBounce }}
className="pointer-events-none absolute top-1 right-1 flex h-4 min-w-4 items-center justify-center overflow-hidden rounded-full bg-destructive px-1 text-[10px] font-semibold leading-none text-destructive-foreground tabular-nums"
>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={display}
initial={reduced ? { opacity: 0 } : { opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={reduced ? { opacity: 0 } : { opacity: 0, y: -8 }}
transition={{ type: "spring", duration: 0.3, bounce: 0 }}
>
{display}
</motion.span>
</AnimatePresence>
</motion.span>
)}
</AnimatePresence>
</button>
);
}