{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "notification-badge",
  "title": "Notification Badge",
  "author": "mrap10",
  "description": "Notification Bell with animated notification badge and pop-up with various configurations.",
  "dependencies": ["motion"],
  "files": [
    {
      "path": "registry/default/pop-ups/notification-badge.tsx",
      "content": "\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport {\n  motion,\n  AnimatePresence,\n  useAnimate,\n  useReducedMotion,\n} from \"motion/react\";\nimport type { Variants } from \"motion/react\";\nimport { useEffect, useRef, useState } from \"react\";\nimport type {\n  Dispatch,\n  FunctionComponent,\n  MouseEventHandler,\n  ReactNode,\n} from \"react\";\n\nexport interface NotificationItem {\n  id: string | number;\n  message: string;\n  timestamp?: string;\n  icon?: ReactNode;\n  unread?: boolean;\n}\n\ntype BadgeVariant = \"dot\" | \"count\";\ntype NotificationId = NotificationItem[\"id\"];\n\ntype NotificationTriggerProps = {\n  open: boolean;\n  onClick: MouseEventHandler<HTMLElement>;\n  unreadCount: number;\n  total: number;\n  items: NotificationItem[];\n};\n\ninterface NotificationBadgeProps {\n  notifications?: NotificationItem[];\n  badgeVariant?: BadgeVariant;\n  panelTitle?: string;\n  markAllReadLabel?: string | null;\n  footerLabel?: string | null;\n  emptyLabel?: string;\n  onNotificationClick?: Dispatch<NotificationItem>;\n  onDismiss?: Dispatch<NotificationItem>;\n  onMarkAllRead?: () => void;\n  onFooterClick?: () => void;\n  trigger?: FunctionComponent<NotificationTriggerProps>;\n  className?: string;\n  panelClassName?: string;\n}\n\nconst panelVariants: Variants = {\n  hidden: { opacity: 0, scale: 0.96, y: -6 },\n  visible: {\n    opacity: 1,\n    scale: 1,\n    y: 0,\n    transition: { duration: 0.2, ease: [0.22, 1, 0.36, 1] },\n  },\n  exit: {\n    opacity: 0,\n    scale: 0.96,\n    y: -6,\n    transition: { duration: 0.15, ease: [0.4, 0, 1, 1], delay: 0.05 },\n  },\n};\n\nconst dotVariants: Variants = {\n  hidden: { scale: 0 },\n  visible: {\n    scale: 1,\n    transition: { type: \"spring\", stiffness: 400, damping: 18 },\n  },\n  exit: {\n    scale: 0,\n    transition: { duration: 0.15 },\n  },\n};\n\nconst bellShakeKeyFrames = [0, 14, -10, 8, -5, 3, -2, 0];\n\nexport default function NotificationBadge({\n  notifications: notificationsProp,\n  badgeVariant = \"dot\",\n  panelTitle = \"Notifications\",\n  markAllReadLabel = \"Mark all as read\",\n  footerLabel = \"View all notifications\",\n  emptyLabel = \"You're all caught up!\",\n  onNotificationClick,\n  onDismiss,\n  onMarkAllRead,\n  onFooterClick,\n  trigger,\n  className,\n  panelClassName,\n}: NotificationBadgeProps) {\n  const [open, setOpen] = useState(false);\n  const [uncontrolledItems, setUncontrolledItems] = useState<\n    NotificationItem[]\n  >(() => notificationsProp ?? []);\n  const [readIds, setReadIds] = useState<Set<NotificationId>>(() => new Set());\n  const [dismissedIds, setDismissedIds] = useState<Set<NotificationId>>(\n    () => new Set()\n  );\n\n  const isControlled = notificationsProp !== undefined;\n  const sourceItems = notificationsProp ?? uncontrolledItems;\n\n  const items = isControlled\n    ? sourceItems\n        .filter((item) => !dismissedIds.has(item.id))\n        .map((item) =>\n          readIds.has(item.id) ? { ...item, unread: false } : item\n        )\n    : sourceItems;\n\n  const wrapperRef = useRef<HTMLDivElement>(null);\n  const [bellRef, animateBell] = useAnimate();\n  const reducedMotion = useReducedMotion();\n\n  const unreadCount = items.filter((item) => item.unread).length;\n  const hasUnread = unreadCount > 0;\n  const total = items.length;\n\n  useEffect(() => {\n    if (!open || reducedMotion || !bellRef.current) return;\n    animateBell(\n      bellRef.current,\n      { rotate: bellShakeKeyFrames },\n      {\n        duration: 0.5,\n        ease: \"easeInOut\",\n      }\n    );\n  }, [open, reducedMotion, animateBell, bellRef]);\n\n  useEffect(() => {\n    if (!open) return;\n    function onMouseDown(e: MouseEvent) {\n      if (\n        wrapperRef.current &&\n        !wrapperRef.current.contains(e.target as Node)\n      ) {\n        setOpen(false);\n      }\n    }\n\n    function onKeyDown(e: KeyboardEvent) {\n      if (e.key === \"Escape\") setOpen(false);\n    }\n\n    document.addEventListener(\"mousedown\", onMouseDown);\n    document.addEventListener(\"keydown\", onKeyDown);\n\n    return () => {\n      document.removeEventListener(\"mousedown\", onMouseDown);\n      document.removeEventListener(\"keydown\", onKeyDown);\n    };\n  }, [open]);\n\n  function handleMarkAllRead() {\n    if (isControlled) {\n      setReadIds(new Set(sourceItems.map((item) => item.id)));\n    } else {\n      setUncontrolledItems((prev) =>\n        prev.map((item) => ({ ...item, unread: false }))\n      );\n    }\n    onMarkAllRead?.();\n  }\n\n  function handleDismiss(item: NotificationItem) {\n    if (isControlled) {\n      setDismissedIds((prev) => new Set(prev).add(item.id));\n    } else {\n      setUncontrolledItems((prev) => prev.filter((i) => i.id !== item.id));\n    }\n    onDismiss?.(item);\n  }\n\n  const Trigger = trigger;\n\n  return (\n    <div\n      ref={wrapperRef}\n      className={cn(\"relative inline-flex flex-col items-center\", className)}\n    >\n      {Trigger ? (\n        <Trigger\n          open={open}\n          onClick={() => setOpen((p) => !p)}\n          unreadCount={unreadCount}\n          total={total}\n          items={items}\n        />\n      ) : (\n        <motion.button\n          ref={bellRef}\n          onClick={() => setOpen((p) => !p)}\n          whileTap={{ scale: 0.97 }}\n          transition={{ type: \"spring\", stiffness: 400, damping: 20 }}\n          aria-label=\"Notifications\"\n          aria-haspopup=\"dialog\"\n          aria-expanded={open}\n          className={cn(\n            \"relative rounded-full p-2\",\n            \"bg-neutral-100 text-neutral-600 transition-colors duration-150 hover:bg-neutral-200 hover:text-neutral-800 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-700 dark:hover:text-neutral-100\",\n            \"focus-visible:ring-2 focus-visible:ring-neutral-400 focus-visible:ring-offset-2 focus-visible:outline-none\"\n          )}\n        >\n          <BellIcon className=\"size-5\" />\n          <AnimatePresence>\n            {hasUnread && (\n              <motion.span\n                key=\"badge\"\n                variants={dotVariants}\n                initial=\"hidden\"\n                animate=\"visible\"\n                exit=\"exit\"\n                aria-hidden=\"true\"\n                className={cn(\n                  \"absolute border-[1.5px] border-white dark:border-neutral-800\",\n                  \"bg-red-500\",\n                  badgeVariant === \"dot\"\n                    ? \"top-1.5 right-1.5 size-2 rounded-full\"\n                    : \"top-0.75 right-0.75 flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[10px] leading-none font-semibold text-white\"\n                )}\n              >\n                {badgeVariant === \"count\" ? unreadCount : null}\n              </motion.span>\n            )}\n          </AnimatePresence>\n        </motion.button>\n      )}\n\n      <AnimatePresence>\n        {open && (\n          <motion.div\n            key=\"panel\"\n            variants={panelVariants}\n            initial=\"hidden\"\n            animate=\"visible\"\n            exit=\"exit\"\n            style={{ transformOrigin: \"top right\" }}\n            className={cn(\n              \"absolute top-[calc(100%+10px)] right-0 z-50 w-72 overflow-hidden rounded-xl\",\n              \"border border-neutral-200 bg-white shadow-lg shadow-black/6 dark:border-neutral-700 dark:bg-neutral-900 dark:shadow-black/30\",\n              panelClassName\n            )}\n            role=\"dialog\"\n            aria-label=\"Notifications panel\"\n          >\n            {(panelTitle || markAllReadLabel) && (\n              <div className=\"flex items-center justify-between border-b border-neutral-100 px-3.5 py-3 dark:border-neutral-800\">\n                {panelTitle && (\n                  <span className=\"text-[13px] font-medium text-neutral-800 dark:text-neutral-100\">\n                    {panelTitle}\n                  </span>\n                )}\n                {markAllReadLabel && hasUnread && (\n                  <button\n                    onClick={handleMarkAllRead}\n                    className=\"text-[11px] text-neutral-400 transition-colors hover:text-neutral-700 dark:text-neutral-500 dark:hover:text-neutral-300\"\n                  >\n                    {markAllReadLabel}\n                  </button>\n                )}\n              </div>\n            )}\n\n            <ul\n              className=\"flex max-h-100 scrollbar-thin flex-col overflow-y-auto p-1.5\"\n              role=\"list\"\n            >\n              <AnimatePresence initial={false}>\n                {total === 0 ? (\n                  <motion.li\n                    key=\"empty\"\n                    initial={{ opacity: 0 }}\n                    animate={{ opacity: 1 }}\n                    className=\"py-6 text-center text-[13px] text-neutral-400 dark:text-neutral-500\"\n                  >\n                    {emptyLabel}\n                  </motion.li>\n                ) : (\n                  items.map((item) => (\n                    <li\n                      key={item.id}\n                      role=\"listitem\"\n                      className={cn(\n                        \"group relative flex cursor-pointer items-start gap-2.5 rounded-lg px-2.5 py-2.5 select-none\",\n                        \"transform-colors duration-100 hover:bg-neutral-100 dark:hover:bg-neutral-800\"\n                      )}\n                      onClick={() => onNotificationClick?.(item)}\n                    >\n                      {item.icon && (\n                        <div className=\"mt-px flex size-7.5 shrink-0 items-center justify-center rounded-full bg-neutral-100 text-sm dark:bg-neutral-800\">\n                          {item.icon}\n                        </div>\n                      )}\n                      <div className=\"min-w-0 flex-1\">\n                        <p className=\"text-[12px] leading-snug text-neutral-800 dark:text-neutral-100\">\n                          {item.message}\n                        </p>\n                        {item.timestamp && (\n                          <p className=\"mt-0.5 text-[11px] text-neutral-400 dark:text-neutral-500\">\n                            {item.timestamp}\n                          </p>\n                        )}\n                      </div>\n                      {item.unread && (\n                        <div className=\"mt-1.5 size-1.5 shrink-0 rounded-full bg-blue-500\" />\n                      )}\n                      <button\n                        aria-label=\"Dismiss notification\"\n                        onClick={(e) => {\n                          e.stopPropagation();\n                          handleDismiss(item);\n                        }}\n                        className={cn(\n                          \"absolute top-2 right-2 flex size-4 items-center justify-center rounded\",\n                          \"text-[11px] text-neutral-300 hover:text-neutral-600 dark:text-neutral-600 dark:hover:text-neutral-300\",\n                          \"opacity-0 transition-opacity group-hover:opacity-100\"\n                        )}\n                      >\n                        <XIcon />\n                      </button>\n                    </li>\n                  ))\n                )}\n              </AnimatePresence>\n            </ul>\n\n            {footerLabel && total > 0 && (\n              <button\n                onClick={onFooterClick}\n                className={cn(\n                  \"w-full cursor-pointer py-2.5 text-center text-[12px]\",\n                  \"text-neutral-400 hover:text-neutral-700 dark:text-neutral-300 dark:hover:text-neutral-50\",\n                  \"border-t border-neutral-100 transition-colors duration-150 dark:border-neutral-800\"\n                )}\n              >\n                {footerLabel}\n              </button>\n            )}\n          </motion.div>\n        )}\n      </AnimatePresence>\n    </div>\n  );\n}\n\nfunction BellIcon({ className }: { className?: string }) {\n  return (\n    <svg\n      viewBox=\"0 0 24 24\"\n      fill=\"currentColor\"\n      className={className}\n      aria-hidden=\"true\"\n    >\n      <path d=\"M14.235 19c.865 0 1.322 1.024 .745 1.668a3.992 3.992 0 0 1 -2.98 1.332a3.992 3.992 0 0 1 -2.98 -1.332c-.552 -.616 -.158 -1.579 .634 -1.661l.11 -.006h4.471z\" />\n      <path d=\"M12 2c1.358 0 2.506 .903 2.875 2.141l.046 .171l.008 .043a8.013 8.013 0 0 1 4.024 6.069l.028 .287l.019 .289v2.931l.021 .136a3 3 0 0 0 1.143 1.847l.167 .117l.162 .099c.86 .487 .56 1.766 -.377 1.864l-.116 .006h-16c-1.028 0 -1.387 -1.364 -.493 -1.87a3 3 0 0 0 1.472 -2.063l.021 -.143l.001 -2.97a8 8 0 0 1 3.821 -6.454l.248 -.146l.01 -.043a3.003 3.003 0 0 1 2.562 -2.29l.182 -.017l.176 -.004z\" />\n    </svg>\n  );\n}\n\nfunction XIcon() {\n  return (\n    <svg\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth=\"2\"\n      strokeLinecap=\"round\"\n      className=\"size-2.5\"\n    >\n      <path d=\"M18 6L6 18M6 6l12 12\" />\n    </svg>\n  );\n}\n",
      "type": "registry:component"
    }
  ],
  "categories": ["pop-ups"],
  "type": "registry:component"
}
