{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "notification-feed",
  "title": "Notification Feed",
  "description": "Layout-animated notifications as a stack, inbox list, dynamic island or timeline cards, with swipe to dismiss, pausable timers and a useNotifications hook.",
  "dependencies": [
    "motion",
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://trytweenly.vercel.app/r/use-reduced-motion.json"
  ],
  "files": [
    {
      "path": "registry/new-york/notification-feed/notification-feed.tsx",
      "content": "\"use client\"\n\nimport {\n  useCallback,\n  useEffect,\n  useEffectEvent,\n  useMemo,\n  useRef,\n  useState,\n  type CSSProperties,\n  type FocusEvent,\n  type MouseEvent,\n  type ReactNode,\n  type RefObject,\n} from \"react\"\nimport { AnimatePresence, animate, motion, useMotionValue, useTransform, type PanInfo, type Transition } from \"motion/react\"\nimport { Bell, CircleCheck, CircleX, Info, TriangleAlert, X } from \"lucide-react\"\nimport { cn } from \"@/lib/utils\"\nimport { useReducedMotion } from \"@/registry/new-york/hooks/use-reduced-motion\"\n\n/* ---------------------------------------------------------------------- types */\n\nexport type NotificationTone = \"default\" | \"success\" | \"warning\" | \"error\" | \"info\"\nexport type NotificationFeedVariant = \"stack\" | \"list\" | \"island\" | \"cards\"\nexport type NotificationPosition = \"top-right\" | \"top-left\" | \"bottom-right\" | \"bottom-left\" | \"top-center\" | \"bottom-center\"\nexport type NotificationExpand = \"hover\" | \"always\" | \"click\"\n\nexport interface NotificationItem {\n  /** Unique identifier. */\n  id: string\n  /** Main line. */\n  title: string\n  /** Supporting text, clamped to two lines. */\n  description?: string\n  /** Custom icon. Defaults to an icon matching the tone. */\n  icon?: ReactNode\n  /** Avatar image URL. Takes precedence over the icon. */\n  avatar?: string\n  /** Timestamp label, e.g. \"2m ago\". */\n  time?: string\n  /** Semantic color. Default: \"default\" */\n  tone?: NotificationTone\n  /** Optional inline action button. */\n  action?: { label: string; onClick: () => void }\n  /** Hides the unread dot in the list variant. Default: false */\n  read?: boolean\n  /** Group heading in the list variant when `grouped` is on. Defaults to \"now\" when unread, \"earlier\" when read. */\n  group?: \"now\" | \"earlier\"\n  /** Per-item auto-dismiss override in ms (0 disables). */\n  duration?: number\n}\n\nexport interface NotificationFeedProps {\n  /** Notifications, newest first. */\n  items: NotificationItem[]\n  /** Called when an item is dismissed by swipe, button or timeout. */\n  onDismiss?: (id: string) => void\n  /** Layout: Sonner-like stack, inbox list, dynamic island, or timeline cards. Default: \"stack\" */\n  variant?: NotificationFeedVariant\n  /** Screen corner for the stack and island variants. Default: \"top-center\" */\n  position?: NotificationPosition\n  /** Positioning context for the stack and island variants. Use \"absolute\" inside a relative container. Default: \"fixed\" */\n  strategy?: \"fixed\" | \"absolute\"\n  /** When the stack or island opens into a full list. Default: \"hover\" */\n  expand?: NotificationExpand\n  /** Maximum number of visible items. Default: 4 */\n  max?: number\n  /** Auto-dismiss delay in ms, paused while hovered. 0 disables. Default: 5000 */\n  duration?: number\n  /** Space between expanded items in px. Default: 12 */\n  gap?: number\n  /** Distance from the container edges in px (stack and island). Default: 24 */\n  offset?: number\n  /** Width of the feed in px. Default: 360 */\n  width?: number\n  /** Accent for the progress line, unread dots and focus rings. Default: \"#ff4d12\" */\n  accent?: string\n  /** Corner radius of items in px. Default: 16 */\n  radius?: number\n  /** List variant: split items under \"Now\" and \"Earlier\" headings. Default: false */\n  grouped?: boolean\n  /** Allow dragging items sideways to dismiss them. Default: true */\n  swipeToDismiss?: boolean\n  /** Shown by the list and cards variants when there are no items. Default: \"You're all caught up\" */\n  emptyState?: ReactNode\n  /** Accessible name of the region. Default: \"Notifications\" */\n  label?: string\n  className?: string\n}\n\n/* -------------------------------------------------------------------- tokens */\n\nconst EASE_OUT = [0.22, 1, 0.36, 1] as const\nconst spring: Transition = { type: \"spring\", stiffness: 420, damping: 34 }\nconst layoutSpring: Transition = { type: \"spring\", stiffness: 500, damping: 40 }\nconst instant: Transition = { duration: 0 }\nconst PEEK = 12\nconst SHRINK = 0.05\n\nconst TONES: Record<NotificationTone, { color: string | null; icon: ReactNode }> = {\n  default: { color: null, icon: <Bell /> },\n  success: { color: \"#22c55e\", icon: <CircleCheck /> },\n  warning: { color: \"#f59e0b\", icon: <TriangleAlert /> },\n  error: { color: \"#ef4444\", icon: <CircleX /> },\n  info: { color: \"#3b82f6\", icon: <Info /> },\n}\n\n/* ---------------------------------------------------------------------- hook */\n\nexport type NotifyInput = Omit<NotificationItem, \"id\"> & { id?: string }\n\nexport interface UseNotificationsOptions {\n  /** Oldest items beyond this count are dropped. Default: 50 */\n  limit?: number\n}\n\n/** Local notification store: `notify` prepends, `dismiss` removes, `clear` empties. */\nexport function useNotifications({ limit = 50 }: UseNotificationsOptions = {}) {\n  const [items, setItems] = useState<NotificationItem[]>([])\n  const seq = useRef(0)\n\n  const notify = useCallback(\n    (input: NotifyInput) => {\n      seq.current += 1\n      const id = input.id ?? `notification-${seq.current}`\n      setItems((prev) => [{ ...input, id }, ...prev.filter((p) => p.id !== id)].slice(0, limit))\n      return id\n    },\n    [limit]\n  )\n  const dismiss = useCallback((id: string) => setItems((prev) => prev.filter((p) => p.id !== id)), [])\n  const clear = useCallback(() => setItems([]), [])\n\n  return useMemo(() => ({ items, notify, dismiss, clear }), [items, notify, dismiss, clear])\n}\n\n/* ----------------------------------------------------------------- item body */\n\nfunction Leading({ item, dark }: { item: NotificationItem; dark?: boolean }) {\n  const tone = TONES[item.tone ?? \"default\"]\n  if (item.avatar) {\n    return (\n      <span className=\"relative shrink-0\">\n        <img src={item.avatar} alt=\"\" className=\"size-9 rounded-full object-cover ring-1 ring-border\" draggable={false} />\n        {tone.color && (\n          <span\n            className={cn(\"absolute -bottom-0.5 -right-0.5 size-3 rounded-full ring-2\", dark ? \"ring-[#0a0a0a]\" : \"ring-card\")}\n            style={{ background: tone.color }}\n          />\n        )}\n      </span>\n    )\n  }\n  const color = tone.color\n  return (\n    <span\n      className={cn(\n        \"grid size-9 shrink-0 place-items-center rounded-full [&_svg]:size-[18px]\",\n        !color && (dark ? \"bg-white/10 text-white/80\" : \"bg-foreground/[0.06] text-foreground/80\")\n      )}\n      style={color ? { background: `color-mix(in oklab, ${color} 16%, transparent)`, color } : undefined}\n    >\n      {item.icon ?? tone.icon}\n    </span>\n  )\n}\n\nfunction ItemBody({ item, dark, leading = true, className }: { item: NotificationItem; dark?: boolean; leading?: boolean; className?: string }) {\n  const muted = dark ? \"text-white/55\" : \"text-muted-foreground\"\n  return (\n    <div className={cn(\"flex items-start gap-3 p-3.5 pr-10\", className)}>\n      {leading && <Leading item={item} dark={dark} />}\n      <div className=\"min-w-0 flex-1\">\n        <div className=\"flex items-baseline gap-2\">\n          <p className=\"truncate text-sm font-medium tracking-tight\">{item.title}</p>\n          {item.time && <span className={cn(\"ml-auto shrink-0 text-[11px] tabular-nums\", muted)}>{item.time}</span>}\n        </div>\n        {item.description && <p className={cn(\"mt-0.5 line-clamp-2 text-[13px] leading-snug\", muted)}>{item.description}</p>}\n        {item.action && (\n          <button\n            type=\"button\"\n            onClick={item.action.onClick}\n            onPointerDown={(e) => e.stopPropagation()}\n            className={cn(\n              \"mt-2.5 inline-flex h-7 items-center rounded-full px-3 text-xs font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-[var(--nf-accent)]\",\n              dark ? \"bg-white/10 hover:bg-white/15\" : \"bg-foreground/[0.06] hover:bg-foreground/[0.1]\"\n            )}\n          >\n            {item.action.label}\n          </button>\n        )}\n      </div>\n    </div>\n  )\n}\n\n/* ---------------------------------------------------------------- item shell */\n\ninterface ShellProps {\n  item: NotificationItem\n  duration: number\n  /** Timer and progress run only while active. */\n  active: boolean\n  paused: boolean\n  swipe: boolean\n  reduced: boolean\n  onDismiss: (id: string) => void\n  children: ReactNode\n  className?: string\n  style?: CSSProperties\n  dark?: boolean\n  /** Animated height in px; omit to size to content. */\n  height?: number\n  onHeight?: (id: string, height: number) => void\n  hideContent?: boolean\n  inert?: boolean\n}\n\nfunction ItemShell({\n  item,\n  duration,\n  active,\n  paused,\n  swipe,\n  reduced,\n  onDismiss,\n  children,\n  className,\n  style,\n  dark,\n  height,\n  onHeight,\n  hideContent,\n  inert,\n}: ShellProps) {\n  const x = useMotionValue(0)\n  const fade = useTransform(x, [-180, 0, 180], [0, 1, 0])\n  const progress = useMotionValue(1)\n  const contentRef = useRef<HTMLDivElement>(null)\n  const [leaving, setLeaving] = useState(false)\n  const id = item.id\n\n  const expire = useEffectEvent(() => onDismiss(id))\n  const measure = useEffectEvent((h: number) => onHeight?.(id, h))\n\n  useEffect(() => {\n    if (!active || paused || leaving || duration <= 0) return\n    const remaining = (progress.get() * duration) / 1000\n    const controls = animate(progress, 0, { duration: remaining, ease: \"linear\", onComplete: () => expire() })\n    return () => controls.stop()\n  }, [active, paused, leaving, duration, progress])\n\n  useEffect(() => {\n    const el = contentRef.current\n    if (!el) return\n    const ro = new ResizeObserver(([entry]) => measure(Math.round(entry.borderBoxSize?.[0]?.blockSize ?? entry.contentRect.height)))\n    ro.observe(el)\n    return () => ro.disconnect()\n  }, [])\n\n  const onDragEnd = async (_: unknown, info: PanInfo) => {\n    const off = info.offset.x\n    const v = info.velocity.x\n    if (Math.abs(off) > 96 || (Math.abs(v) > 650 && Math.abs(off) > 24)) {\n      setLeaving(true)\n      const dir = Math.sign(off || v)\n      await animate(x, dir * 440, reduced ? instant : { duration: 0.24, ease: EASE_OUT })\n      onDismiss(id)\n      return\n    }\n    animate(x, 0, reduced ? instant : spring)\n  }\n\n  return (\n    <motion.div\n      drag={swipe && !inert && !leaving ? \"x\" : false}\n      dragMomentum={false}\n      dragDirectionLock\n      onDragEnd={onDragEnd}\n      initial={false}\n      animate={height === undefined ? undefined : { height }}\n      transition={reduced ? instant : spring}\n      onKeyDown={(e) => {\n        if ((e.key === \"Delete\" || e.key === \"Backspace\") && e.target === e.currentTarget) onDismiss(id)\n      }}\n      tabIndex={inert ? -1 : 0}\n      aria-hidden={inert || undefined}\n      aria-label={item.description ? `${item.title}. ${item.description}` : item.title}\n      className={cn(\n        \"group/item relative touch-pan-y overflow-hidden outline-none focus-visible:ring-2 focus-visible:ring-[var(--nf-accent)]\",\n        swipe && \"cursor-grab active:cursor-grabbing\",\n        className\n      )}\n      style={{ ...style, x, opacity: fade, pointerEvents: inert ? \"none\" : undefined }}\n    >\n      <motion.div\n        ref={contentRef}\n        initial={false}\n        animate={{ opacity: hideContent ? 0 : 1 }}\n        transition={reduced ? instant : { duration: 0.2 }}\n      >\n        {children}\n      </motion.div>\n\n      <button\n        type=\"button\"\n        aria-label={`Dismiss ${item.title}`}\n        tabIndex={inert ? -1 : 0}\n        onPointerDown={(e) => e.stopPropagation()}\n        onClick={() => onDismiss(id)}\n        className={cn(\n          \"absolute right-2.5 top-2.5 grid size-6 place-items-center rounded-full opacity-0 outline-none transition-opacity duration-150\",\n          \"focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-[var(--nf-accent)] group-focus-within/item:opacity-100 [@media(hover:hover)]:group-hover/item:opacity-100\",\n          dark ? \"bg-white/10 text-white/70 hover:text-white\" : \"bg-foreground/[0.06] text-muted-foreground hover:text-foreground\",\n          hideContent && \"hidden\"\n        )}\n      >\n        <X className=\"size-3.5\" />\n      </button>\n\n      {active && duration > 0 && !hideContent && (\n        <motion.div\n          aria-hidden\n          className=\"absolute inset-x-0 bottom-0 h-[2px] origin-left\"\n          style={{ scaleX: progress, background: \"var(--nf-accent)\" }}\n        />\n      )}\n    </motion.div>\n  )\n}\n\n/* ------------------------------------------------------------------ variants */\n\ninterface ViewProps {\n  items: NotificationItem[]\n  onDismiss: (id: string) => void\n  max: number\n  duration: number\n  gap: number\n  radius: number\n  paused: boolean\n  expanded: boolean\n  swipe: boolean\n  reduced: boolean\n  emptyState: ReactNode\n  grouped: boolean\n  vertical: \"top\" | \"bottom\"\n  horizontal: \"left\" | \"right\" | \"center\"\n  areaRef: RefObject<HTMLDivElement | null>\n  bind: AreaHandlers\n}\n\ninterface AreaHandlers {\n  onPointerEnter: () => void\n  onPointerLeave: () => void\n  onFocus: (e: FocusEvent<HTMLElement>) => void\n  onBlur: (e: FocusEvent<HTMLElement>) => void\n  onClick?: (e: MouseEvent<HTMLElement>) => void\n}\n\nconst itemDuration = (item: NotificationItem, fallback: number) => item.duration ?? fallback\n\nfunction StackView({ items, onDismiss, max, duration, gap, radius, paused, expanded, swipe, reduced, vertical, areaRef, bind }: ViewProps) {\n  const [heights, setHeights] = useState<Record<string, number>>({})\n  const onHeight = useCallback((id: string, h: number) => setHeights((prev) => (prev[id] === h ? prev : { ...prev, [id]: h })), [])\n\n  const s = vertical === \"bottom\" ? -1 : 1\n  const count = Math.min(items.length, max)\n  const heightOf = (i: number) => heights[items[i]?.id] ?? 72\n  const front = heightOf(0)\n\n  const offsets: number[] = []\n  let acc = 0\n  for (let i = 0; i < items.length; i++) {\n    offsets.push(acc)\n    if (i < count) acc += heightOf(i) + gap\n  }\n  const expandedH = Math.max(0, acc - gap)\n  const listH = count === 0 ? 0 : expanded ? expandedH : front + (count - 1) * PEEK\n\n  return (\n    <div\n      ref={areaRef}\n      {...bind}\n      className=\"relative transition-[height] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]\"\n      style={{ height: listH }}\n    >\n      <ol className=\"contents\">\n        <AnimatePresence initial={false}>\n          {items.map((item, i) => {\n            const hidden = i >= max\n            const depth = Math.min(i, max - 1)\n            const collapsedBack = !expanded && i > 0\n            const target = hidden\n              ? { y: s * (expanded ? offsets[count - 1] ?? 0 : depth * PEEK), scale: 1 - depth * SHRINK, opacity: 0 }\n              : expanded\n                ? { y: s * offsets[i], scale: 1, opacity: 1 }\n                : { y: s * i * PEEK, scale: 1 - i * SHRINK, opacity: 1 }\n            return (\n              <motion.li\n                key={item.id}\n                className=\"absolute inset-x-0\"\n                style={{ [vertical]: 0, zIndex: items.length - i, transformOrigin: vertical === \"bottom\" ? \"50% 0%\" : \"50% 100%\" }}\n                initial={{ y: -s * 56, opacity: 0, scale: 0.96 }}\n                animate={target}\n                exit={{ opacity: 0, scale: 0.92, transition: reduced ? instant : { duration: 0.2, ease: EASE_OUT } }}\n                transition={reduced ? instant : spring}\n              >\n                <ItemShell\n                  item={item}\n                  duration={itemDuration(item, duration)}\n                  active={!hidden}\n                  paused={paused}\n                  swipe={swipe}\n                  reduced={reduced}\n                  onDismiss={onDismiss}\n                  onHeight={onHeight}\n                  height={collapsedBack ? front : heights[item.id]}\n                  hideContent={collapsedBack}\n                  inert={hidden}\n                  className=\"bg-card text-card-foreground shadow-lg shadow-black/[0.08] ring-1 ring-border\"\n                  style={{ borderRadius: radius }}\n                >\n                  <ItemBody item={item} />\n                </ItemShell>\n              </motion.li>\n            )\n          })}\n        </AnimatePresence>\n      </ol>\n    </div>\n  )\n}\n\nconst contentSwap = {\n  initial: { opacity: 0, scale: 0.92, filter: \"blur(6px)\" },\n  animate: { opacity: 1, scale: 1, filter: \"blur(0px)\" },\n  exit: { opacity: 0, scale: 0.92, filter: \"blur(6px)\" },\n}\n\nfunction IslandView({ items, onDismiss, max, duration, radius, paused, expanded, swipe, reduced, horizontal, areaRef, bind }: ViewProps) {\n  const idle = items.length === 0\n  const current = items[0]\n  const mode = idle ? \"idle\" : expanded ? \"list\" : \"single\"\n  const swapTransition: Transition = reduced ? instant : { duration: 0.32, ease: EASE_OUT, delay: 0.06 }\n\n  return (\n    <div className={cn(\"flex\", horizontal === \"left\" ? \"justify-start\" : horizontal === \"right\" ? \"justify-end\" : \"justify-center\")}>\n      <motion.div\n        ref={areaRef}\n        {...bind}\n        layout\n        initial={false}\n        animate={{ borderRadius: idle ? 22 : radius + 10 }}\n        transition={reduced ? instant : layoutSpring}\n        className=\"relative overflow-hidden bg-[#0a0a0a] text-[#ededed] shadow-2xl shadow-black/30 ring-1 ring-white/10\"\n        style={{ width: idle ? 124 : \"100%\" }}\n      >\n        <AnimatePresence mode=\"popLayout\" initial={false}>\n          {mode === \"idle\" && (\n            <motion.div key=\"idle\" layout=\"position\" {...contentSwap} transition={swapTransition} className=\"flex h-9 items-center justify-end px-3.5\">\n              <span className=\"size-2 rounded-full bg-white/15\" />\n            </motion.div>\n          )}\n\n          {mode === \"single\" && current && (\n            <motion.div key={`single-${current.id}`} layout=\"position\" {...contentSwap} transition={swapTransition} className=\"relative\">\n              <ItemShell\n                item={current}\n                duration={itemDuration(current, duration)}\n                active\n                paused={paused}\n                swipe={swipe}\n                reduced={reduced}\n                onDismiss={onDismiss}\n                dark\n              >\n                <ItemBody item={current} dark />\n              </ItemShell>\n              {items.length > 1 && (\n                <span className=\"pointer-events-none absolute bottom-2.5 right-3 rounded-full bg-white/10 px-1.5 py-0.5 text-[10px] font-medium tabular-nums text-white/70\">\n                  +{items.length - 1}\n                </span>\n              )}\n            </motion.div>\n          )}\n\n          {mode === \"list\" && (\n            <motion.ol key=\"list\" layout=\"position\" {...contentSwap} transition={swapTransition} className=\"relative flex flex-col p-1.5\">\n              <AnimatePresence initial={false} mode=\"popLayout\">\n                {items.slice(0, max).map((item, i) => (\n                  <motion.li\n                    key={item.id}\n                    layout=\"position\"\n                    initial={{ opacity: 0, y: -10 }}\n                    animate={{ opacity: 1, y: 0 }}\n                    exit={{ opacity: 0, transition: { duration: reduced ? 0 : 0.16 } }}\n                    transition={reduced ? instant : layoutSpring}\n                    className={cn(i > 0 && \"border-t border-white/[0.07]\")}\n                  >\n                    <ItemShell\n                      item={item}\n                      duration={itemDuration(item, duration)}\n                      active\n                      paused={paused}\n                      swipe={swipe}\n                      reduced={reduced}\n                      onDismiss={onDismiss}\n                      dark\n                      style={{ borderRadius: radius }}\n                    >\n                      <ItemBody item={item} dark />\n                    </ItemShell>\n                  </motion.li>\n                ))}\n              </AnimatePresence>\n              {items.length > max && (\n                <li className=\"px-3.5 pb-1.5 pt-1 text-[11px] text-white/45\">+{items.length - max} more</li>\n              )}\n            </motion.ol>\n          )}\n        </AnimatePresence>\n      </motion.div>\n    </div>\n  )\n}\n\ntype Entry = { kind: \"heading\"; key: string; label: string } | { kind: \"item\"; key: string; item: NotificationItem; index: number }\n\nfunction ListView({ items, onDismiss, max, duration, radius, paused, swipe, reduced, emptyState, grouped, areaRef, bind }: ViewProps) {\n  const visible = items.slice(0, max)\n  const entries: Entry[] = []\n  if (grouped) {\n    const groupOf = (n: NotificationItem) => n.group ?? (n.read ? \"earlier\" : \"now\")\n    for (const g of [\"now\", \"earlier\"] as const) {\n      const members = visible.filter((n) => groupOf(n) === g)\n      if (!members.length) continue\n      entries.push({ kind: \"heading\", key: `heading-${g}`, label: g === \"now\" ? \"Now\" : \"Earlier\" })\n      for (const item of members) entries.push({ kind: \"item\", key: item.id, item, index: visible.indexOf(item) })\n    }\n  } else {\n    visible.forEach((item, index) => entries.push({ kind: \"item\", key: item.id, item, index }))\n  }\n  const transition = reduced ? instant : layoutSpring\n\n  return (\n    <motion.div\n      ref={areaRef}\n      {...bind}\n      layout\n      transition={transition}\n      className=\"relative overflow-hidden bg-card text-card-foreground shadow-sm ring-1 ring-border\"\n      style={{ borderRadius: radius }}\n    >\n      <ol className=\"relative flex flex-col\">\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          {entries.map((entry, i) =>\n            entry.kind === \"heading\" ? (\n              <motion.li\n                key={entry.key}\n                layout=\"position\"\n                initial={{ opacity: 0 }}\n                animate={{ opacity: 1 }}\n                exit={{ opacity: 0, transition: { duration: 0.12 } }}\n                transition={transition}\n                className={cn(\"px-4 pb-1 pt-3 text-[11px] font-medium uppercase tracking-wider text-muted-foreground\", i > 0 && \"border-t\")}\n              >\n                {entry.label}\n              </motion.li>\n            ) : (\n              <motion.li\n                key={entry.key}\n                layout=\"position\"\n                initial={{ opacity: 0, y: -14 }}\n                animate={{ opacity: 1, y: 0 }}\n                exit={{ opacity: 0, transition: { duration: reduced ? 0 : 0.16 } }}\n                transition={transition}\n                className={cn(\"relative\", i > 0 && entries[i - 1]?.kind === \"item\" && \"border-t\")}\n              >\n                <ItemShell\n                  item={entry.item}\n                  duration={itemDuration(entry.item, duration)}\n                  active\n                  paused={paused}\n                  swipe={swipe}\n                  reduced={reduced}\n                  onDismiss={onDismiss}\n                  className=\"bg-card transition-colors hover:bg-foreground/[0.02]\"\n                >\n                  {!entry.item.read && (\n                    <span aria-label=\"Unread\" className=\"absolute left-1.5 top-[26px] size-1.5 rounded-full\" style={{ background: \"var(--nf-accent)\" }} />\n                  )}\n                  <ItemBody item={entry.item} className=\"pl-4\" />\n                </ItemShell>\n              </motion.li>\n            )\n          )}\n          {items.length > max && (\n            <motion.li\n              key=\"more\"\n              layout=\"position\"\n              initial={{ opacity: 0 }}\n              animate={{ opacity: 1 }}\n              exit={{ opacity: 0 }}\n              transition={transition}\n              className=\"border-t px-4 py-2.5 text-xs text-muted-foreground\"\n            >\n              {items.length - max} more\n            </motion.li>\n          )}\n          {items.length === 0 && (\n            <motion.li\n              key=\"empty\"\n              layout=\"position\"\n              initial={{ opacity: 0 }}\n              animate={{ opacity: 1 }}\n              exit={{ opacity: 0, transition: { duration: 0.1 } }}\n              transition={reduced ? instant : { duration: 0.3, ease: EASE_OUT, delay: 0.15 }}\n              className=\"flex items-center justify-center gap-2 px-4 py-8 text-sm text-muted-foreground\"\n            >\n              <Bell className=\"size-4\" />\n              {emptyState}\n            </motion.li>\n          )}\n        </AnimatePresence>\n      </ol>\n    </motion.div>\n  )\n}\n\nfunction CardsView({ items, onDismiss, max, duration, gap, radius, paused, swipe, reduced, emptyState, areaRef, bind }: ViewProps) {\n  const visible = items.slice(0, max)\n  const transition = reduced ? instant : layoutSpring\n\n  return (\n    <motion.div ref={areaRef} {...bind} layout transition={transition} className=\"relative\">\n      <motion.span layout transition={transition} aria-hidden className=\"absolute bottom-5 left-[17px] top-5 w-px bg-border\" />\n      <ol className=\"relative flex flex-col\" style={{ gap }}>\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          {visible.map((item, i) => (\n            <motion.li\n              key={item.id}\n              layout=\"position\"\n              initial={{ opacity: 0, y: -18, scale: 0.96 }}\n              animate={{ opacity: 1 - i * 0.12, y: 0, scale: 1 - i * 0.025 }}\n              exit={{ opacity: 0, scale: 0.94, transition: { duration: reduced ? 0 : 0.18 } }}\n              transition={reduced ? instant : { ...layoutSpring, opacity: { duration: 0.3 } }}\n              className=\"relative flex items-start gap-3\"\n              style={{ transformOrigin: \"0% 50%\", zIndex: visible.length - i }}\n            >\n              <span className=\"relative z-10 mt-3.5 shrink-0 rounded-full bg-card ring-4 ring-card\">\n                <Leading item={item} />\n              </span>\n              <ItemShell\n                item={item}\n                duration={itemDuration(item, duration)}\n                active\n                paused={paused}\n                swipe={swipe}\n                reduced={reduced}\n                onDismiss={onDismiss}\n                className=\"min-w-0 flex-1 bg-card text-card-foreground ring-1 ring-border\"\n                style={{\n                  borderRadius: radius,\n                  boxShadow: `0 ${2 + (max - i) * 2}px ${8 + (max - i) * 6}px -6px rgb(0 0 0 / ${0.06 + (max - i) * 0.02})`,\n                }}\n              >\n                <ItemBody item={item} leading={false} />\n              </ItemShell>\n            </motion.li>\n          ))}\n          {items.length === 0 && (\n            <motion.li\n              key=\"empty\"\n              layout=\"position\"\n              initial={{ opacity: 0 }}\n              animate={{ opacity: 1 }}\n              exit={{ opacity: 0, transition: { duration: 0.1 } }}\n              transition={reduced ? instant : { duration: 0.3, ease: EASE_OUT, delay: 0.15 }}\n              className=\"flex items-center gap-3 py-2 pl-2.5 text-sm text-muted-foreground\"\n            >\n              <span className=\"relative z-10 grid size-4 place-items-center rounded-full bg-card ring-4 ring-card\">\n                <span className=\"size-2 rounded-full bg-border\" />\n              </span>\n              {emptyState}\n            </motion.li>\n          )}\n        </AnimatePresence>\n      </ol>\n    </motion.div>\n  )\n}\n\n/* ------------------------------------------------------------ NotificationFeed */\n\nexport function NotificationFeed({\n  items,\n  onDismiss,\n  variant = \"stack\",\n  position = \"top-center\",\n  strategy = \"fixed\",\n  expand = \"hover\",\n  max = 4,\n  duration = 5000,\n  gap = 12,\n  offset = 24,\n  width = 360,\n  accent = \"#ff4d12\",\n  radius = 16,\n  grouped = false,\n  swipeToDismiss = true,\n  emptyState = \"You're all caught up\",\n  label = \"Notifications\",\n  className,\n}: NotificationFeedProps) {\n  const reduced = useReducedMotion()\n  const areaRef = useRef<HTMLDivElement>(null)\n  const [hovered, setHovered] = useState(false)\n  const [focused, setFocused] = useState(false)\n  const [clicked, setClicked] = useState(false)\n\n  // The area can vanish under the pointer when the last item leaves; start fresh next time\n  if (items.length === 0 && (hovered || focused || clicked)) {\n    setHovered(false)\n    setFocused(false)\n    setClicked(false)\n  }\n\n  useEffect(() => {\n    if (expand !== \"click\" || !clicked) return\n    const onDown = (e: PointerEvent) => {\n      if (!areaRef.current?.contains(e.target as Node)) setClicked(false)\n    }\n    document.addEventListener(\"pointerdown\", onDown)\n    return () => document.removeEventListener(\"pointerdown\", onDown)\n  }, [expand, clicked])\n\n  const dismiss = useCallback((id: string) => onDismiss?.(id), [onDismiss])\n  const expanded = expand === \"always\" || (expand === \"hover\" ? hovered || focused : clicked)\n  const paused = hovered || focused || (expand === \"click\" && clicked)\n\n  const bind: AreaHandlers = {\n    onPointerEnter: () => setHovered(true),\n    onPointerLeave: () => setHovered(false),\n    onFocus: (e) => {\n      if (e.target.matches(\":focus-visible\")) setFocused(true)\n    },\n    onBlur: (e) => {\n      if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setFocused(false)\n    },\n    onClick:\n      expand === \"click\"\n        ? (e) => {\n            if ((e.target as HTMLElement).closest(\"button\")) return\n            setClicked((c) => !c)\n          }\n        : undefined,\n  }\n\n  const vertical = position.startsWith(\"top\") ? \"top\" : \"bottom\"\n  const horizontal = position.endsWith(\"left\") ? \"left\" : position.endsWith(\"right\") ? \"right\" : \"center\"\n  const floating = variant === \"stack\" || variant === \"island\"\n\n  const view: ViewProps = {\n    items,\n    onDismiss: dismiss,\n    max: Math.max(1, max),\n    duration,\n    gap,\n    radius,\n    paused,\n    expanded,\n    swipe: swipeToDismiss,\n    reduced,\n    emptyState,\n    grouped,\n    vertical,\n    horizontal,\n    areaRef,\n    bind,\n  }\n\n  const accentVar = { \"--nf-accent\": accent } as CSSProperties\n  const content =\n    variant === \"stack\" ? (\n      <StackView {...view} />\n    ) : variant === \"island\" ? (\n      <IslandView {...view} />\n    ) : variant === \"list\" ? (\n      <ListView {...view} />\n    ) : (\n      <CardsView {...view} />\n    )\n\n  if (!floating) {\n    return (\n      <section aria-label={label} aria-live=\"polite\" className={cn(\"relative w-full\", className)} style={{ ...accentVar, maxWidth: width + (variant === \"cards\" ? 48 : 0) }}>\n        {content}\n      </section>\n    )\n  }\n\n  const place: CSSProperties =\n    horizontal === \"center\" ? { left: \"50%\", transform: \"translateX(-50%)\" } : { [horizontal]: offset }\n\n  return (\n    <section\n      aria-label={label}\n      aria-live=\"polite\"\n      className={cn(\"pointer-events-none\", className)}\n      style={{\n        ...accentVar,\n        ...place,\n        position: strategy,\n        [vertical]: offset,\n        zIndex: strategy === \"fixed\" ? 100 : 30,\n        width: `min(${width}px, calc(100% - ${offset * 2}px))`,\n      }}\n    >\n      <div className=\"pointer-events-auto\">{content}</div>\n    </section>\n  )\n}\n",
      "type": "registry:component"
    }
  ],
  "docs": "Docs, live playground and every prop: https://trytweenly.vercel.app/docs/components/notification-feed",
  "type": "registry:component"
}