Feedback

Notification Bell

Bell button whose badge pops in with a bounce, rolls count changes vertically, and swings the bell once on new arrivals.

Install

npx shadcn@latest add @paragon/notification-bell

notification-bell.tsx

"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>
  );
}