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