{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"infinite-masonry","type":"registry:block","title":"Infinite Masonry","description":"Responsive virtualized masonry that measures variable-height cards and loads more data as the user nears the end.","author":"Saurabh <saurabh10102@gmail.com>","dependencies":["@tanstack/react-virtual","clsx","lucide-react","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/infinite-masonry.tsx","type":"registry:component","target":"@components/motion/infinite-masonry.tsx","content":"\"use client\";\n// beui.dev/components/blocks/infinite-masonry\n\nimport { useVirtualizer } from \"@tanstack/react-virtual\";\nimport { AlertCircle, Inbox } from \"lucide-react\";\nimport { motion, useReducedMotion } from \"motion/react\";\nimport {\n  useEffect,\n  useRef,\n  useState,\n  type MutableRefObject,\n  type ReactNode,\n} from \"react\";\nimport { EASE_OUT, SPRING_PANEL } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport type InfiniteMasonryKey = string | number | bigint;\n\nexport interface InfiniteMasonryProps<T> {\n  items: readonly T[];\n  getItemKey: (item: T, index: number) => InfiniteMasonryKey;\n  renderItem: (item: T, index: number) => ReactNode;\n  onLoadMore: () => void | Promise<void>;\n  hasMore: boolean;\n  loading?: boolean;\n  error?: ReactNode;\n  onRetry?: () => void;\n  estimateSize?: (item: T, index: number) => number;\n  renderLoadingItem?: (index: number) => ReactNode;\n  emptyState?: ReactNode;\n  endState?: ReactNode;\n  minColumnWidth?: number;\n  maxColumns?: number;\n  gap?: number;\n  overscan?: number;\n  prefetch?: number;\n  animateItems?: boolean;\n  ariaLabel?: string;\n  className?: string;\n  contentClassName?: string;\n  itemClassName?: string;\n}\n\ntype MasonryMetrics = {\n  columns: number;\n  width: number;\n};\n\nfunction useMasonryMetrics({\n  elementRef,\n  gap,\n  maxColumns,\n  minColumnWidth,\n}: {\n  elementRef: React.RefObject<HTMLDivElement | null>;\n  gap: number;\n  maxColumns: number;\n  minColumnWidth: number;\n}) {\n  const [metrics, setMetrics] = useState<MasonryMetrics>({\n    columns: 1,\n    width: 0,\n  });\n\n  useEffect(() => {\n    const element = elementRef.current;\n    if (!element) return;\n\n    const update = (width: number) => {\n      const columns = Math.min(\n        maxColumns,\n        Math.max(1, Math.floor((width + gap) / (minColumnWidth + gap))),\n      );\n\n      setMetrics((current) =>\n        current.columns === columns && current.width === width\n          ? current\n          : { columns, width },\n      );\n    };\n\n    update(element.getBoundingClientRect().width);\n\n    const observer = new ResizeObserver(([entry]) => {\n      update(entry.contentRect.width);\n    });\n    observer.observe(element);\n\n    return () => observer.disconnect();\n  }, [elementRef, gap, maxColumns, minColumnWidth]);\n\n  return metrics;\n}\n\nfunction DefaultLoadingItem({ index }: { index: number }) {\n  return (\n    <div\n      aria-hidden=\"true\"\n      className=\"animate-pulse rounded-2xl border border-border bg-card p-3\"\n      style={{ minHeight: 144 + (index % 3) * 36 }}\n    >\n      <div className=\"h-3 w-2/3 rounded-full bg-muted\" />\n      <div className=\"mt-3 h-2 w-full rounded-full bg-muted\" />\n      <div className=\"mt-2 h-2 w-4/5 rounded-full bg-muted\" />\n    </div>\n  );\n}\n\nfunction DefaultEmptyState() {\n  return (\n    <div className=\"flex h-full min-h-64 flex-col items-center justify-center px-6 text-center\">\n      <Inbox className=\"size-8 text-muted-foreground\" aria-hidden=\"true\" />\n      <p className=\"mt-3 text-sm font-medium text-foreground\">No items yet</p>\n      <p className=\"mt-1 max-w-xs text-xs leading-5 text-muted-foreground\">\n        New items will appear here when they become available.\n      </p>\n    </div>\n  );\n}\n\nfunction MasonryItemReveal({\n  itemKey,\n  lane,\n  revealedKeys,\n  animate,\n  children,\n}: {\n  itemKey: InfiniteMasonryKey;\n  lane: number;\n  revealedKeys: MutableRefObject<Set<InfiniteMasonryKey>>;\n  animate: boolean;\n  children: ReactNode;\n}) {\n  const [shouldReveal] = useState(\n    () => animate && !revealedKeys.current.has(itemKey),\n  );\n\n  useEffect(() => {\n    revealedKeys.current.add(itemKey);\n  }, [itemKey, revealedKeys]);\n\n  const delay = Math.min(lane, 3) * 0.04;\n\n  return (\n    <motion.div\n      initial={shouldReveal ? { opacity: 0, y: 12 } : false}\n      animate={{ opacity: 1, y: 0 }}\n      transition={{\n        y: { ...SPRING_PANEL, delay },\n        opacity: { duration: 0.2, ease: EASE_OUT, delay },\n      }}\n    >\n      {children}\n    </motion.div>\n  );\n}\n\nexport function InfiniteMasonry<T>({\n  items,\n  getItemKey,\n  renderItem,\n  onLoadMore,\n  hasMore,\n  loading = false,\n  error,\n  onRetry,\n  estimateSize = () => 240,\n  renderLoadingItem = (index) => <DefaultLoadingItem index={index} />,\n  emptyState = <DefaultEmptyState />,\n  endState,\n  minColumnWidth = 208,\n  maxColumns = 4,\n  gap = 12,\n  overscan = 4,\n  prefetch = 3,\n  animateItems = true,\n  ariaLabel = \"Infinite masonry feed\",\n  className,\n  contentClassName,\n  itemClassName,\n}: InfiniteMasonryProps<T>) {\n  const reduceMotion = useReducedMotion();\n  const scrollRef = useRef<HTMLDivElement>(null);\n  const contentRef = useRef<HTMLDivElement>(null);\n  const loadMoreRef = useRef(onLoadMore);\n  const loadPendingRef = useRef(false);\n  const initialItemCountRef = useRef(items.length);\n  const revealedKeysRef = useRef(new Set<InfiniteMasonryKey>());\n  const { columns, width } = useMasonryMetrics({\n    elementRef: contentRef,\n    gap,\n    maxColumns,\n    minColumnWidth,\n  });\n\n  useEffect(() => {\n    loadMoreRef.current = onLoadMore;\n  }, [onLoadMore]);\n\n  useEffect(() => {\n    if (!loading) loadPendingRef.current = false;\n  }, [loading]);\n\n  const hasError = error !== undefined && error !== null;\n  const tailCount = hasError ? 1 : loading ? columns : 0;\n  const virtualizer = useVirtualizer({\n    count: items.length + tailCount,\n    getScrollElement: () => scrollRef.current,\n    getItemKey: (index) =>\n      index < items.length\n        ? getItemKey(items[index], index)\n        : `masonry-tail-${index - items.length}`,\n    estimateSize: (index) =>\n      index < items.length\n        ? estimateSize(items[index], index)\n        : 144 + ((index - items.length) % 3) * 36,\n    gap,\n    lanes: columns,\n    overscan: overscan * columns,\n  });\n\n  const virtualItems = virtualizer.getVirtualItems();\n  const viewportEnd =\n    (virtualizer.scrollOffset ?? 0) + (virtualizer.scrollRect?.height ?? 0);\n  const lastVisibleIndex = virtualItems.reduce(\n    (lastIndex, item) =>\n      item.start < viewportEnd ? Math.max(lastIndex, item.index) : lastIndex,\n    -1,\n  );\n\n  useEffect(() => {\n    if (\n      hasError ||\n      loading ||\n      !hasMore ||\n      loadPendingRef.current ||\n      lastVisibleIndex < Math.max(0, items.length - prefetch)\n    ) {\n      return;\n    }\n\n    loadPendingRef.current = true;\n    void Promise.resolve(loadMoreRef.current()).finally(() => {\n      loadPendingRef.current = false;\n    });\n  }, [hasError, hasMore, items.length, lastVisibleIndex, loading, prefetch]);\n\n  if (items.length === 0 && !hasMore && !loading && !hasError) {\n    return (\n      <section\n        aria-label={ariaLabel}\n        className={cn(\n          \"w-full overflow-hidden rounded-3xl border border-border bg-background\",\n          className,\n        )}\n      >\n        {emptyState}\n      </section>\n    );\n  }\n\n  const columnWidth =\n    columns > 0 ? Math.max(0, (width - gap * (columns - 1)) / columns) : 0;\n\n  return (\n    <section\n      ref={scrollRef}\n      aria-label={ariaLabel}\n      aria-busy={loading}\n      className={cn(\n        \"w-full contain-[layout_paint] overflow-y-auto overscroll-none rounded-3xl border border-border bg-background p-3 [overflow-anchor:none] [scrollbar-gutter:stable]\",\n        className,\n      )}\n    >\n      <div\n        ref={contentRef}\n        className={cn(\"relative w-full\", contentClassName)}\n        style={{ height: virtualizer.getTotalSize() }}\n      >\n        {virtualItems.map((virtualItem) => {\n          const isTail = virtualItem.index >= items.length;\n          const tailIndex = virtualItem.index - items.length;\n\n          return (\n            <div\n              key={virtualItem.key}\n              ref={virtualizer.measureElement}\n              data-index={virtualItem.index}\n              className={cn(\n                \"absolute left-0 top-0 will-change-transform\",\n                !isTail && itemClassName,\n              )}\n              style={{\n                width: columnWidth,\n                transform: `translate3d(${virtualItem.lane * (columnWidth + gap)}px, ${virtualItem.start}px, 0)`,\n              }}\n            >\n              {isTail ? (\n                hasError ? (\n                  <div className=\"flex min-h-36 flex-col items-start justify-center rounded-2xl border border-destructive/20 bg-destructive/5 p-4\">\n                    <div className=\"flex items-center gap-2 text-destructive\">\n                      <AlertCircle className=\"size-4\" aria-hidden=\"true\" />\n                      <p className=\"text-sm font-medium\">Couldn&apos;t load more</p>\n                    </div>\n                    <div className=\"mt-2 text-xs leading-5 text-muted-foreground\">\n                      {error}\n                    </div>\n                    {onRetry ? (\n                      <button\n                        type=\"button\"\n                        onClick={onRetry}\n                        className=\"mt-3 min-h-10 rounded-full border border-border bg-background px-4 text-xs font-medium text-foreground outline-none transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2\"\n                      >\n                        Try again\n                      </button>\n                    ) : null}\n                  </div>\n                ) : (\n                  renderLoadingItem(tailIndex)\n                )\n              ) : (\n                <MasonryItemReveal\n                  itemKey={virtualItem.key}\n                  lane={virtualItem.lane}\n                  revealedKeys={revealedKeysRef}\n                  animate={\n                    animateItems &&\n                    !reduceMotion &&\n                    virtualItem.index >= initialItemCountRef.current\n                  }\n                >\n                  {renderItem(items[virtualItem.index], virtualItem.index)}\n                </MasonryItemReveal>\n              )}\n            </div>\n          );\n        })}\n      </div>\n\n      {!hasMore && items.length > 0 && endState ? (\n        <div className=\"py-4 text-center text-xs text-muted-foreground\">\n          {endState}\n        </div>\n      ) : null}\n      <span className=\"sr-only\" aria-live=\"polite\">\n        {loading ? \"Loading more items\" : null}\n      </span>\n    </section>\n  );\n}\n"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\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.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\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"}]}