{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "notification-stack",
  "title": "Notification Stack",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "macOS Notification Center cards: collapsed, the newest sits in front with two peeking behind; click or press Enter to spring the stack open. New cards slide in from the top; each can be dismissed.",
  "dependencies": [
    "motion@^12",
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/i18n.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/notification-stack.tsx",
      "content": "// Ballmac UI: Notification Stack. https://ui.ballmac.com/components/notification-stack\n\"use client\"\n\nimport * as React from \"react\"\nimport { AnimatePresence, MotionConfig, motion, type Transition } from \"motion/react\"\nimport { ChevronUpIcon, XIcon } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { useMessages } from \"@/lib/ballmac/i18n\"\n\nconst stackSpring: Transition = { type: \"spring\", stiffness: 380, damping: 32, mass: 0.9 }\n\n/** Offset in px of each peeking card below the one in front. */\nconst PEEK = 9\n/** Horizontal inset in px of each peeking card. */\nconst INSET = 10\n\ntype NotificationStackProps = Omit<React.ComponentProps<\"section\">, \"children\"> & {\n  /** Notification elements, newest first. Give each a stable `key`; new keys animate in from the top. */\n  children?: React.ReactNode\n  /** Controlled expanded state. */\n  expanded?: boolean\n  /** Initial expanded state when uncontrolled. */\n  defaultExpanded?: boolean\n  /** Called when the stack expands or collapses. */\n  onExpandedChange?: (expanded: boolean) => void\n  /** Shows a \"clear all\" button when set. */\n  onClearAll?: () => void\n  /** Heading shown above the expanded list; also the region's accessible name. */\n  label?: string\n}\n\n/**\n * Notifications grouped like macOS Notification Center: collapsed, the newest card sits in front with two\n * more peeking out behind it; activating the stack springs it open into a list.\n */\nfunction NotificationStack({\n  children,\n  expanded: expandedProp,\n  defaultExpanded = false,\n  onExpandedChange,\n  onClearAll,\n  label,\n  className,\n  onKeyDown,\n  ...props\n}: NotificationStackProps) {\n  const msg = useMessages()\n  label ??= msg(\"notification-stack.label\", \"Notifications\")\n  const [uncontrolled, setUncontrolled] = React.useState(defaultExpanded)\n  const expanded = expandedProp ?? uncontrolled\n  const items = React.Children.toArray(children).filter(React.isValidElement)\n  const count = items.length\n  const stacked = !expanded && count > 1\n  const peeking = Math.min(count, 3) - 1\n\n  const expandRef = React.useRef<HTMLButtonElement>(null)\n  const collapseRef = React.useRef<HTMLButtonElement>(null)\n  const focusAfter = React.useRef<\"expand\" | \"collapse\" | null>(null)\n\n  const setExpanded = (next: boolean, moveFocus = false) => {\n    if (moveFocus) focusAfter.current = next ? \"collapse\" : \"expand\"\n    if (expandedProp === undefined) setUncontrolled(next)\n    onExpandedChange?.(next)\n  }\n\n  // Keep focus on the control that replaces the one just used.\n  React.useEffect(() => {\n    if (focusAfter.current === \"collapse\") collapseRef.current?.focus()\n    if (focusAfter.current === \"expand\") expandRef.current?.focus()\n    focusAfter.current = null\n  }, [expanded])\n\n  const headingId = React.useId()\n\n  return (\n    <MotionConfig reducedMotion=\"user\">\n      <section\n        aria-labelledby={headingId}\n        data-slot=\"notification-stack\"\n        data-state={expanded ? \"expanded\" : \"collapsed\"}\n        className={cn(\"group/stack relative w-full\", className)}\n        onKeyDown={(event) => {\n          onKeyDown?.(event)\n          if (!event.defaultPrevented && event.key === \"Escape\" && expanded && count > 1) {\n            event.preventDefault()\n            setExpanded(false, true)\n          }\n        }}\n        {...props}\n      >\n        <div\n          data-slot=\"notification-stack-header\"\n          className={cn(\"flex items-center gap-2 px-1 transition-[height,opacity] duration-200\", expanded && count > 1 ? \"mb-2 h-7 opacity-100\" : \"h-0 overflow-hidden opacity-0\")}\n        >\n          <h3 id={headingId} className=\"flex-1 truncate text-[15px] font-semibold tracking-tight text-foreground\">\n            {label}\n          </h3>\n          {expanded && count > 1 ? (\n            <>\n              <button\n                ref={collapseRef}\n                type=\"button\"\n                aria-expanded=\"true\"\n                onClick={() => setExpanded(false, true)}\n                className=\"inline-flex h-7 items-center gap-1 rounded-full bg-background/70 px-3 text-xs font-medium text-foreground/80 shadow-[0_0_0_0.5px_rgb(0_0_0/0.12)] backdrop-blur-xl outline-none transition-colors hover:bg-background/90 focus-visible:ring-[3px] focus-visible:ring-ring/50 dark:shadow-[0_0_0_0.5px_rgb(255_255_255/0.12)]\"\n              >\n                <ChevronUpIcon className=\"size-3.5\" aria-hidden=\"true\" />\n                {msg(\"notification-stack.showLess\", \"Show less\")}\n              </button>\n              {onClearAll ? (\n                <button\n                  type=\"button\"\n                  aria-label={msg(\"notification-stack.clearAllNotifications\", \"Clear all notifications\")}\n                  onClick={onClearAll}\n                  className=\"inline-flex size-7 items-center justify-center rounded-full bg-background/70 text-foreground/70 shadow-[0_0_0_0.5px_rgb(0_0_0/0.12)] backdrop-blur-xl outline-none transition-colors hover:bg-background/90 focus-visible:ring-[3px] focus-visible:ring-ring/50 dark:shadow-[0_0_0_0.5px_rgb(255_255_255/0.12)]\"\n                >\n                  <XIcon className=\"size-3.5\" aria-hidden=\"true\" />\n                </button>\n              ) : null}\n            </>\n          ) : null}\n        </div>\n\n        <motion.div layout=\"size\" transition={stackSpring} className=\"relative\" style={{ paddingBottom: stacked ? peeking * PEEK : 0 }}>\n          <ul className=\"relative flex flex-col gap-2\">\n            <AnimatePresence initial={false} mode=\"popLayout\">\n              {items.map((child, index) => {\n                const back = stacked && index > 0\n                // Collapsed, the expand button stands in for the whole stack; the front card describes it.\n                const covered = stacked && index === 0\n                const depth = Math.min(index, 2)\n                const hidden = stacked && index > 2\n                return (\n                  <motion.li\n                    key={child.key ?? index}\n                    layout\n                    data-slot=\"notification-stack-item\"\n                    data-peek={back ? \"\" : undefined}\n                    id={covered ? `${headingId}-front` : undefined}\n                    aria-hidden={back || undefined}\n                    inert={back || covered || undefined}\n                    initial={{ opacity: 0, y: -28, scale: 0.94 }}\n                    animate={{ opacity: hidden ? 0 : 1, y: 0, scale: 1 }}\n                    exit={{ opacity: 0, scale: 0.9, transition: { duration: 0.18 } }}\n                    transition={stackSpring}\n                    className={cn(\"list-none\", back ? \"absolute\" : \"relative\", \"[&[data-peek]_[data-slot=notification-content]]:opacity-0\")}\n                    style={{\n                      zIndex: count - index,\n                      ...(back ? { top: depth * PEEK, bottom: -depth * PEEK, left: depth * INSET, right: depth * INSET } : {}),\n                    }}\n                  >\n                    {child}\n                  </motion.li>\n                )\n              })}\n            </AnimatePresence>\n          </ul>\n\n          {stacked ? (\n            <button\n              ref={expandRef}\n              type=\"button\"\n              aria-expanded=\"false\"\n              aria-label={msg(\"notification-stack.showAllNotifications\", \"Show all {count} notifications\", { count })}\n              aria-describedby={`${headingId}-front`}\n              data-slot=\"notification-stack-expand\"\n              onClick={() => setExpanded(true, true)}\n              className=\"absolute inset-0 z-[1000] rounded-[18px] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50\"\n            />\n          ) : null}\n          {stacked && onClearAll ? (\n            <button\n              type=\"button\"\n              aria-label={msg(\"notification-stack.clearAllNotifications\", \"Clear all notifications\")}\n              onClick={onClearAll}\n              className=\"absolute -top-2 -start-2 z-[1001] flex size-6 items-center justify-center rounded-full bg-background/90 text-foreground/70 opacity-0 shadow-[0_0_0_0.5px_rgb(0_0_0/0.18),0_2px_6px_rgb(0_0_0/0.15)] backdrop-blur-xl outline-none transition-opacity group-hover/stack:opacity-100 focus-visible:opacity-100 focus-visible:ring-[3px] focus-visible:ring-ring/50 [@media(hover:none)]:opacity-100\"\n            >\n              <XIcon className=\"size-3\" aria-hidden=\"true\" />\n            </button>\n          ) : null}\n        </motion.div>\n      </section>\n    </MotionConfig>\n  )\n}\n\ntype NotificationProps = Omit<React.ComponentProps<\"div\">, \"title\"> & {\n  /** App icon, drawn in a 36px rounded tile. Style the tile with iconClassName. */\n  icon?: React.ReactNode\n  /** Classes for the icon tile (background gradient, color). */\n  iconClassName?: string\n  /** Bold first line. */\n  title: React.ReactNode\n  /** Short relative time, e.g. \"now\" or \"9m ago\". */\n  time?: React.ReactNode\n  /** Shows a dismiss button (on hover and focus) that calls this. */\n  onDismiss?: () => void\n  /** Accessible label of the dismiss button. */\n  dismissLabel?: string\n}\n\n/** One notification card: icon, title, time and body text (children), with translucent vibrancy. */\nfunction Notification({ icon, iconClassName, title, time, onDismiss, dismissLabel, className, children, ...props }: NotificationProps) {\n  const msg = useMessages()\n  dismissLabel ??= msg(\"notification-stack.dismissLabel\", \"Dismiss\")\n  return (\n    <div\n      data-slot=\"notification\"\n      className={cn(\n        \"group/notification relative h-full rounded-[18px] border border-white/40 bg-background/70 p-3 text-start shadow-[0_0_0_0.5px_rgb(0_0_0/0.1),0_8px_24px_-10px_rgb(0_0_0/0.3)] backdrop-blur-2xl backdrop-saturate-150\",\n        \"dark:border-white/10 dark:bg-background/60 dark:shadow-[0_0_0_0.5px_rgb(0_0_0/0.6),0_8px_24px_-10px_rgb(0_0_0/0.6)]\",\n        className\n      )}\n      {...props}\n    >\n      <div data-slot=\"notification-content\" className=\"flex gap-3 transition-opacity duration-200\">\n        {icon ? (\n          <span\n            data-slot=\"notification-icon\"\n            className={cn(\n              \"flex size-9 shrink-0 items-center justify-center overflow-hidden rounded-[9px] bg-muted text-foreground shadow-[inset_0_0_0_0.5px_rgb(255_255_255/0.2)] [&_svg:not([class*='size-'])]:size-5\",\n              iconClassName\n            )}\n          >\n            {icon}\n          </span>\n        ) : null}\n        <div className=\"min-w-0 flex-1\">\n          <div className=\"flex items-baseline gap-2\">\n            <p className=\"min-w-0 flex-1 truncate text-[13px] font-semibold text-foreground\">{title}</p>\n            {time ? <span className=\"shrink-0 text-xs text-muted-foreground\">{time}</span> : null}\n          </div>\n          {children ? <div className=\"mt-0.5 line-clamp-2 text-[13px] leading-snug text-foreground/75\">{children}</div> : null}\n        </div>\n      </div>\n      {onDismiss ? (\n        <button\n          type=\"button\"\n          aria-label={dismissLabel}\n          onClick={onDismiss}\n          data-slot=\"notification-dismiss\"\n          className=\"absolute -top-2 -start-2 flex size-6 items-center justify-center rounded-full bg-background/90 text-foreground/70 opacity-0 shadow-[0_0_0_0.5px_rgb(0_0_0/0.18),0_2px_6px_rgb(0_0_0/0.15)] backdrop-blur-xl outline-none transition-opacity group-hover/notification:opacity-100 focus-visible:opacity-100 focus-visible:ring-[3px] focus-visible:ring-ring/50 [@media(hover:none)]:opacity-100\"\n        >\n          <XIcon className=\"size-3\" aria-hidden=\"true\" />\n        </button>\n      ) : null}\n    </div>\n  )\n}\n\nexport { Notification, NotificationStack, type NotificationProps, type NotificationStackProps }\n",
      "type": "registry:ui",
      "target": "@components/ballmac/notification-stack.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "notifications",
      "toast",
      "stack",
      "macos",
      "notification center",
      "motion",
      "vibrancy"
    ],
    "version": "1.0.1",
    "updated": "2026-09-29",
    "ai": {
      "summary": "Render <NotificationStack> with <Notification key icon iconClassName title time onDismiss>body</Notification> children, newest first. Keep items in state; prepend to add (animates in), filter to dismiss. expanded/defaultExpanded/onExpandedChange control the stack; onClearAll adds a clear button.",
      "whenToUse": [
        "An activity or notification feed in a dashboard corner",
        "A landing-page hero showing the alerts a product sends (deploys, reviews, payments)",
        "Grouping several recent events so they take the space of one"
      ],
      "whenNotToUse": [
        "Transient feedback after a single action (use a toast)",
        "A single live activity that changes over time (use dynamic-island)"
      ],
      "composesWith": [
        "dynamic-island",
        "mac-window"
      ],
      "a11y": [
        {
          "keys": "Tab",
          "action": "Focuses the stack's expand button when collapsed, or each card's dismiss button and Show less when expanded"
        },
        {
          "keys": "Enter / Space",
          "action": "Expands the collapsed stack (focus moves to Show less) or activates the focused button"
        },
        {
          "keys": "Escape",
          "action": "Collapses an expanded stack and returns focus to it"
        }
      ],
      "customization": [
        "expanded / defaultExpanded / onExpandedChange: controlled or uncontrolled",
        "label: heading and region name (default Notifications); onClearAll shows clear buttons",
        "Notification: icon + iconClassName (tile background), title, time, children (body, clamped to 2 lines), onDismiss, dismissLabel",
        "Peeking cards are inert and hidden from assistive tech until the stack expands"
      ]
    },
    "examples": [
      "notification-stack-demo"
    ],
    "url": "https://ui.ballmac.com/components/notification-stack"
  },
  "categories": [
    "macos"
  ],
  "type": "registry:ui"
}