{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"notification-stack","type":"registry:component","title":"Notification Stack","description":"A deck of notification cards: collapsed to the top card with peeking edges behind it, fanning into a readable list on hover or focus.","author":"Ryan","dependencies":["clsx","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/notification-stack.tsx","type":"registry:component","target":"@components/motion/notification-stack.tsx","content":"\"use client\";\n// easeui.dev/components/motion/notification-stack\n\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { type FocusEvent, type ReactNode, useState } from \"react\";\nimport { SPRING_LAYOUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface NotificationStackItem {\n  id: string;\n  title: string;\n  description?: string;\n  /** A small trailing element, such as a retry count or a status icon. */\n  trailing?: ReactNode;\n}\n\nexport interface NotificationStackProps {\n  items: NotificationStackItem[];\n  /** Controlled expanded state. */\n  expanded?: boolean;\n  /** Starting state when uncontrolled. Default false. */\n  defaultExpanded?: boolean;\n  onExpandedChange?: (expanded: boolean) => void;\n  /** How many cards peek out behind the top one when collapsed. Default 3. */\n  maxVisible?: number;\n  emptyLabel?: string;\n  className?: string;\n}\n\nfunction Card({ item }: { item: NotificationStackItem }) {\n  return (\n    <motion.div\n      layout\n      initial={{ opacity: 0, y: -8, scale: 0.96 }}\n      animate={{ opacity: 1, y: 0, scale: 1 }}\n      exit={{ opacity: 0, scale: 0.96, transition: { duration: 0.15 } }}\n      transition={SPRING_LAYOUT}\n      style={{ willChange: \"transform\" }}\n      className=\"relative z-10 flex items-start gap-3 rounded-xl bg-background p-3 shadow-[0_0_0_1px_var(--border)]\"\n    >\n      <span className=\"min-w-0 flex-1\">\n        <span className=\"block truncate text-sm font-medium text-foreground\">{item.title}</span>\n        {item.description ? (\n          <span className=\"mt-0.5 block truncate text-xs text-muted-foreground\">{item.description}</span>\n        ) : null}\n      </span>\n      {item.trailing ? <span className=\"shrink-0 text-xs font-medium\">{item.trailing}</span> : null}\n    </motion.div>\n  );\n}\n\n/**\n * A deck of notification cards: collapsed to the top card with peeking\n * edges behind it, fanning into a readable list on hover or focus.\n */\nexport function NotificationStack({\n  items,\n  expanded: expandedProp,\n  defaultExpanded = false,\n  onExpandedChange,\n  maxVisible = 3,\n  emptyLabel = \"All caught up\",\n  className,\n}: NotificationStackProps) {\n  const [uncontrolled, setUncontrolled] = useState(defaultExpanded);\n  const isControlled = expandedProp !== undefined;\n  const expanded = isControlled ? expandedProp : uncontrolled;\n\n  const setExpanded = (next: boolean) => {\n    if (!isControlled) setUncontrolled(next);\n    onExpandedChange?.(next);\n  };\n\n  const onBlur = (event: FocusEvent<HTMLDivElement>) => {\n    if (!event.currentTarget.contains(event.relatedTarget as Node)) setExpanded(false);\n  };\n\n  if (items.length === 0) {\n    return (\n      <p\n        className={cn(\n          \"rounded-xl bg-background p-3 text-center text-xs text-muted-foreground shadow-[0_0_0_1px_var(--border)]\",\n          className,\n        )}\n      >\n        {emptyLabel}\n      </p>\n    );\n  }\n\n  const peek = items.slice(0, maxVisible);\n\n  return (\n    // biome-ignore lint/a11y/noStaticElementInteractions: only tracks hover/focus to expand the stack; any interactive trailing content underneath is its own focusable control.\n    <div\n      onMouseEnter={() => setExpanded(true)}\n      onMouseLeave={() => setExpanded(false)}\n      onFocus={() => setExpanded(true)}\n      onBlur={onBlur}\n      className={cn(\"relative isolate flex flex-col gap-2\", className)}\n    >\n      {!expanded && peek.length > 1 ? (\n        <span\n          aria-hidden=\"true\"\n          className=\"absolute inset-x-2 top-2 -z-10 h-12 rounded-xl bg-card shadow-[0_0_0_1px_var(--border)]\"\n        />\n      ) : null}\n      {!expanded && peek.length > 2 ? (\n        <span\n          aria-hidden=\"true\"\n          className=\"absolute inset-x-4 top-4 -z-20 h-12 rounded-xl bg-card/70 shadow-[0_0_0_1px_var(--border)]\"\n        />\n      ) : null}\n      <AnimatePresence mode=\"popLayout\" initial={false}>\n        {(expanded ? items : peek.slice(0, 1)).map((item) => (\n          <Card key={item.id} item={item} />\n        ))}\n      </AnimatePresence>\n    </div>\n  );\n}\n"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","content":"// Shared motion tokens. Micro-interactions run 100 to 150ms, standard UI\n// 150 to 250ms, and panels up to 300ms.\n// Easing curves mirror the CSS custom properties in globals.css.\n\n/** ease-out-quint. Fast start that settles quickly. Entrances, exits, feedback. */\nexport const EASE_OUT = [0.23, 1, 0.32, 1] as const;\n/** ease-in-out-cubic. Elements already on screen moving to a new spot. */\nexport const EASE_IN_OUT = [0.645, 0.045, 0.355, 1] as const;\n/** Sheet and drawer glide. */\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.23, 1, 0.32, 1)\";\n\n// Springs are described by duration and bounce, which is easier to reason about\n// than stiffness and damping. Bounce stays at zero for product UI.\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  duration: 0.15,\n  bounce: 0,\n} as const;\n\n/** Content swaps, label and icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  duration: 0.2,\n  bounce: 0,\n} as const;\n\n/** Overlay panel entrances, modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  duration: 0.25,\n  bounce: 0,\n} as const;\n\n/** Shared-layout glides, pills and indicators moving between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  duration: 0.22,\n  bounce: 0,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt). */\nexport const SPRING_MOUSE = {\n  stiffness: 320,\n  damping: 26,\n  mass: 0.3,\n} as const;\n\n/** Dragged handles and fills (sliders). Critically damped `useSpring` config,\n * so the value follows the pointer closely and never rebounds off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n} as const;\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"}]}