{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "notification-center",
  "title": "Notification Center",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A bell with an unread badge that opens grouped notifications with All and Unread views, mark-as-read, dismiss, and an unread count in its accessible name.",
  "dependencies": [
    "motion@^12",
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/motion-presets.json",
    "https://ui.ballmac.com/r/popover.json",
    "https://ui.ballmac.com/r/i18n.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/notification-center.tsx",
      "content": "// Ballmac UI: Notification Center. https://ui.ballmac.com/components/notification-center\n\"use client\";\n\nimport * as React from \"react\";\nimport { Bell, BellOff, Check, CheckCheck, X } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { Popover, PopoverContent, PopoverTrigger } from \"@/components/ballmac/popover\";\nimport { spring } from \"@/lib/ballmac/motion\";\nimport { cn } from \"@/lib/utils\";\nimport { useMessages } from \"@/lib/ballmac/i18n\";\n\ntype NotificationItem = {\n  /** Unique id. */\n  id: string;\n  /** What happened, for example \"Ana commented on Launch plan\". */\n  title: string;\n  /** Optional detail line. */\n  description?: string;\n  /** Time text such as \"5m\" or \"Yesterday\". */\n  time: string;\n  /** Group heading such as \"Today\". Items with the same group are listed together, in order. */\n  group?: string;\n  /** Whether it was read. */\n  read?: boolean;\n  /** Leading icon or avatar. */\n  icon?: React.ReactNode;\n  /** Makes the row a link. */\n  href?: string;\n};\n\ntype NotificationCenterProps = Omit<React.ComponentProps<\"button\">, \"onChange\"> & {\n  /** Notifications, newest first. */\n  notifications: NotificationItem[];\n  /** Called when one is marked read (by opening it or with the check button). */\n  onMarkRead?: (id: string) => void;\n  /** Called by \"Mark all as read\". */\n  onMarkAllRead?: () => void;\n  /** Adds a dismiss button on each row. */\n  onDismiss?: (id: string) => void;\n  /** Called when a row is chosen. */\n  onOpenItem?: (item: NotificationItem) => void;\n  /** Open state, for controlled use. */\n  open?: boolean;\n  /** Initial open state. */\n  defaultOpen?: boolean;\n  /** Called when the panel opens or closes. */\n  onOpenChange?: (open: boolean) => void;\n  /** Accessible name of the bell button. The unread count is added. */\n  label?: string;\n  /** Message when there is nothing to show. */\n  emptyText?: string;\n  /** Side of the bell the panel opens on. */\n  align?: \"start\" | \"center\" | \"end\";\n};\n\n/**\n * A bell with an unread badge that opens a panel of notifications, grouped by day with All and Unread views.\n * The count is part of the button's accessible name, new items are announced politely, and every row is keyboard reachable.\n */\nfunction NotificationCenter({\n  notifications,\n  onMarkRead,\n  onMarkAllRead,\n  onDismiss,\n  onOpenItem,\n  open,\n  defaultOpen,\n  onOpenChange,\n  label,\n  emptyText,\n  align = \"end\",\n  className,\n  ...props\n}: NotificationCenterProps) {\n  const msg = useMessages()\n  label ??= msg(\"notification-center.label\", \"Notifications\")\n  emptyText ??= msg(\"notification-center.emptyText\", \"You're all caught up.\")\n  const reduce = useReducedMotion();\n  const [tab, setTab] = React.useState<\"all\" | \"unread\">(\"all\");\n  const unread = notifications.filter((n) => !n.read).length;\n  const shown = tab === \"unread\" ? notifications.filter((n) => !n.read) : notifications;\n  const groups = shown.reduce<Array<{ name: string; items: NotificationItem[] }>>((acc, item) => {\n    const name = item.group ?? \"\";\n    const last = acc.at(-1);\n    if (last && last.name === name) last.items.push(item);\n    else acc.push({ name, items: [item] });\n    return acc;\n  }, []);\n\n  return (\n    <Popover open={open} defaultOpen={defaultOpen} onOpenChange={onOpenChange}>\n      <PopoverTrigger\n        data-slot=\"notification-center\"\n        aria-label={unread ? `${label}, ${unread} unread` : label}\n        className={cn(\n          \"relative inline-flex size-10 items-center justify-center rounded-full border bg-background text-foreground shadow-xs outline-none transition-colors hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50 data-[state=open]:bg-accent\",\n          className,\n        )}\n        {...props}\n      >\n        <Bell aria-hidden=\"true\" className=\"size-[18px]\" />\n        <AnimatePresence>\n          {unread > 0 && (\n            <motion.span\n              key=\"badge\"\n              aria-hidden=\"true\"\n              initial={reduce ? false : { scale: 0 }}\n              animate={{ scale: 1 }}\n              exit={reduce ? { opacity: 0 } : { scale: 0 }}\n              transition={spring.bouncy}\n              className=\"absolute -top-1 -end-1 flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-semibold text-white tabular-nums ring-2 ring-background\"\n            >\n              {unread > 9 ? \"9+\" : unread}\n            </motion.span>\n          )}\n        </AnimatePresence>\n      </PopoverTrigger>\n      <PopoverContent\n        label={label}\n        align={align}\n        className=\"w-[min(25rem,calc(100vw-1.5rem))] gap-0 overflow-hidden p-0\"\n      >\n        <div className=\"flex items-center justify-between gap-3 px-4 pt-3.5 pb-2\">\n          <h3 className=\"text-sm font-semibold tracking-tight\">{label}</h3>\n          <button\n            type=\"button\"\n            onClick={onMarkAllRead}\n            disabled={!unread}\n            className=\"inline-flex h-7 items-center gap-1.5 rounded-md px-2 text-xs font-medium text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50\"\n          >\n            <CheckCheck aria-hidden=\"true\" className=\"size-3.5\" /> {msg(\"notification-center.markAllAsRead\", \"Mark all as read\")}\n          </button>\n        </div>\n        <div role=\"tablist\" aria-label={msg(\"notification-center.filterNotifications\", \"Filter notifications\")} className=\"flex gap-1 border-b px-3\">\n          {([\"all\", \"unread\"] as const).map((t) => (\n            <button\n              key={t}\n              role=\"tab\"\n              type=\"button\"\n              aria-selected={tab === t}\n              onClick={() => setTab(t)}\n              className={cn(\n                \"relative -mb-px flex h-9 items-center gap-1.5 px-2 text-[13px] font-medium outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-ring/50\",\n                tab === t ? \"text-foreground\" : \"text-muted-foreground hover:text-foreground\",\n              )}\n            >\n              {t === \"all\" ? \"All\" : \"Unread\"}\n              {t === \"unread\" && unread > 0 && (\n                <span className=\"rounded-full bg-muted px-1.5 text-[11px] tabular-nums\">{unread}</span>\n              )}\n              {tab === t && (\n                <motion.span layoutId=\"notification-tab\" className=\"absolute inset-x-1 bottom-0 h-0.5 rounded-full bg-primary\" transition={reduce ? { duration: 0 } : spring.snappy} />\n              )}\n            </button>\n          ))}\n        </div>\n\n        <div role=\"tabpanel\" aria-label={tab === \"all\" ? msg(\"notification-center.allNotifications\", \"All notifications\") : msg(\"notification-center.unreadNotifications\", \"Unread notifications\")} tabIndex={0} className=\"max-h-[22rem] overflow-y-auto outline-none focus-visible:ring-[3px] focus-visible:ring-inset focus-visible:ring-ring/50\">\n          {groups.length === 0 ? (\n            <div className=\"flex flex-col items-center gap-2 px-6 py-12 text-center text-muted-foreground\">\n              <BellOff aria-hidden=\"true\" className=\"size-8 opacity-60\" />\n              <p className=\"text-sm\">{tab === \"unread\" ? \"No unread notifications.\" : emptyText}</p>\n            </div>\n          ) : (\n            groups.map((group, gi) => (\n              <div key={`${group.name}-${gi}`}>\n                {group.name && (\n                  <p className=\"sticky top-0 z-10 bg-popover/95 px-4 py-1.5 text-[11px] font-semibold tracking-wide text-muted-foreground uppercase backdrop-blur\">\n                    {group.name}\n                  </p>\n                )}\n                <ul aria-live=\"polite\">\n                  <AnimatePresence initial={false}>\n                    {group.items.map((n) => {\n                      const Row = n.href ? \"a\" : \"div\";\n                      return (\n                        <motion.li\n                          key={n.id}\n                          initial={reduce ? false : { opacity: 0, height: 0 }}\n                          animate={{ opacity: 1, height: \"auto\" }}\n                          exit={reduce ? { opacity: 0 } : { opacity: 0, height: 0 }}\n                          transition={reduce ? { duration: 0 } : spring.snappy}\n                          className=\"group/n relative\"\n                        >\n                          <Row\n                            {...(n.href ? { href: n.href } : { tabIndex: 0, role: \"button\" })}\n                            onClick={() => {\n                              onOpenItem?.(n);\n                              if (!n.read) onMarkRead?.(n.id);\n                            }}\n                            onKeyDown={(e: React.KeyboardEvent) => {\n                              if (!n.href && (e.key === \"Enter\" || e.key === \" \")) {\n                                e.preventDefault();\n                                onOpenItem?.(n);\n                                if (!n.read) onMarkRead?.(n.id);\n                              }\n                            }}\n                            className={cn(\n                              \"flex gap-3 px-4 py-3 pe-16 text-start outline-none transition-colors hover:bg-accent/60 focus-visible:bg-accent focus-visible:ring-[3px] focus-visible:ring-inset focus-visible:ring-ring/50\",\n                              !n.read && \"bg-chart-1/[0.05]\",\n                            )}\n                          >\n                            <span className=\"relative mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground [&_svg]:size-4\">\n                              {n.icon ?? <Bell aria-hidden=\"true\" />}\n                              {!n.read && (\n                                <span className=\"absolute -top-0.5 -end-0.5 size-2.5 rounded-full bg-chart-1 ring-2 ring-popover\">\n                                  <span className=\"sr-only\">{msg(\"notification-center.unread\", \"Unread\")}</span>\n                                </span>\n                              )}\n                            </span>\n                            <span className=\"min-w-0 flex-1\">\n                              <span className={cn(\"block text-sm leading-snug\", !n.read ? \"font-medium\" : \"text-muted-foreground\")}>{n.title}</span>\n                              {n.description && <span className=\"mt-0.5 line-clamp-2 block text-[13px] leading-snug text-muted-foreground\">{n.description}</span>}\n                              <span className=\"mt-1 block text-xs text-muted-foreground tabular-nums\">{n.time}</span>\n                            </span>\n                          </Row>\n                          <div className=\"absolute top-2.5 end-2 flex gap-0.5 opacity-0 transition-opacity group-focus-within/n:opacity-100 group-hover/n:opacity-100 motion-reduce:transition-none\">\n                            {!n.read && onMarkRead && (\n                              <button\n                                type=\"button\"\n                                aria-label={msg(\"notification-center.markAsRead\", \"Mark \\\"{title}\\\" as read\", { title: n.title })}\n                                onClick={() => onMarkRead(n.id)}\n                                className=\"inline-flex size-7 items-center justify-center rounded-md text-muted-foreground outline-none hover:bg-background hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50\"\n                              >\n                                <Check aria-hidden=\"true\" className=\"size-3.5\" />\n                              </button>\n                            )}\n                            {onDismiss && (\n                              <button\n                                type=\"button\"\n                                aria-label={msg(\"notification-center.dismiss\", \"Dismiss \\\"{title}\\\"\", { title: n.title })}\n                                onClick={() => onDismiss(n.id)}\n                                className=\"inline-flex size-7 items-center justify-center rounded-md text-muted-foreground outline-none hover:bg-background hover:text-destructive focus-visible:ring-[3px] focus-visible:ring-ring/50\"\n                              >\n                                <X aria-hidden=\"true\" className=\"size-3.5\" />\n                              </button>\n                            )}\n                          </div>\n                        </motion.li>\n                      );\n                    })}\n                  </AnimatePresence>\n                </ul>\n              </div>\n            ))\n          )}\n        </div>\n      </PopoverContent>\n    </Popover>\n  );\n}\n\nexport { NotificationCenter, type NotificationCenterProps, type NotificationItem };\n",
      "type": "registry:ui",
      "target": "@components/ballmac/notification-center.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "notifications",
      "inbox",
      "bell",
      "popover"
    ],
    "version": "1.0.0",
    "updated": "2026-09-30",
    "ai": {
      "summary": "notifications[] with {id,title,description,time,group,read,icon,href}. Handle onMarkRead, onMarkAllRead, onDismiss and onOpenItem in your own state.",
      "whenToUse": [
        "App headers",
        "In-app activity and alerts"
      ],
      "whenNotToUse": [
        "Transient confirmations; use toast",
        "Long-form updates; use changelog-feed"
      ],
      "composesWith": [
        "popover",
        "avatar",
        "toast"
      ],
      "a11y": [
        {
          "keys": "Enter / Space",
          "action": "Opens the panel; Escape closes and restores focus"
        },
        {
          "keys": "Screen readers",
          "action": "The button announces the unread count; unread rows include 'Unread'; the list is a polite live region"
        },
        {
          "keys": "Tab",
          "action": "Row actions appear on hover and on keyboard focus"
        }
      ],
      "customization": [
        "groups",
        "onOpenChange and open",
        "emptyText and label",
        "row icons"
      ]
    },
    "examples": [
      "notification-center-demo",
      "notification-center-states"
    ],
    "url": "https://ui.ballmac.com/components/notification-center"
  },
  "categories": [
    "saas"
  ],
  "type": "registry:ui"
}