{"slug":"infinite-masonry","name":"Infinite Masonry","description":"Responsive virtualized masonry that measures variable-height cards and loads more data as the user nears the end.","category":"blocks","source_url":"https://beui.dev/r/infinite-masonry/raw","detail_url":"https://beui.dev/r/infinite-masonry","raw_url":"https://beui.dev/r/infinite-masonry/raw","page_url":"https://beui.dev/components/blocks/infinite-masonry","markdown_url":"https://beui.dev/components/blocks/infinite-masonry.md","published_at":"2026-07-15","updated_at":"2026-07-15","dependencies":["@tanstack/react-virtual","clsx","lucide-react","motion","next","react","tailwind-merge"],"internal":["@/components/motion/infinite-masonry","@/lib/ease","@/lib/utils"],"files":[{"path":"components/motion/infinite-masonry.tsx","type":"component","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":"util","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\n/** Dragged handles and fills (sliders) — critically damped `useSpring` config,\n * so the value follows the pointer butterily 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":"util","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"},{"path":"components/previews/blocks/infinite-masonry.preview.tsx","type":"preview","content":"\"use client\";\n\nimport Image from \"next/image\";\nimport { useCallback, useState } from \"react\";\nimport { InfiniteMasonry } from \"@/components/motion/infinite-masonry\";\n\ntype GalleryItem = {\n  id: string;\n  title: string;\n  category: string;\n  image: string;\n  imageHeight: number;\n};\n\nconst PAGE_SIZE = 6;\nconst MAX_ITEMS = 42;\n\nconst gallerySource = [\n  {\n    title: \"Soft geometry\",\n    category: \"Architecture\",\n    image:\n      \"https://images.unsplash.com/photo-1511818966892-d7d671e672a2?auto=format&fit=crop&w=720&q=80\",\n    imageHeight: 260,\n  },\n  {\n    title: \"Open horizon\",\n    category: \"Landscape\",\n    image:\n      \"https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?auto=format&fit=crop&w=720&q=80\",\n    imageHeight: 190,\n  },\n  {\n    title: \"Working rhythm\",\n    category: \"Workspace\",\n    image:\n      \"https://images.unsplash.com/photo-1497366754035-f200968a6e72?auto=format&fit=crop&w=720&q=80\",\n    imageHeight: 230,\n  },\n  {\n    title: \"Shared table\",\n    category: \"Studio\",\n    image:\n      \"https://images.unsplash.com/photo-1524758631624-e2822e304c36?auto=format&fit=crop&w=720&q=80\",\n    imageHeight: 300,\n  },\n  {\n    title: \"In session\",\n    category: \"People\",\n    image:\n      \"https://images.unsplash.com/photo-1521737711867-e3b97375f902?auto=format&fit=crop&w=720&q=80\",\n    imageHeight: 210,\n  },\n  {\n    title: \"After hours\",\n    category: \"Office\",\n    image:\n      \"https://images.unsplash.com/photo-1504384308090-c894fdcc538d?auto=format&fit=crop&w=720&q=80\",\n    imageHeight: 280,\n  },\n] as const;\n\nfunction createItems(start: number, count: number): GalleryItem[] {\n  return Array.from({ length: count }, (_, offset) => {\n    const index = start + offset;\n    const source = gallerySource[index % gallerySource.length];\n\n    return {\n      ...source,\n      id: `gallery-${index}`,\n      title: `${source.title} ${Math.floor(index / gallerySource.length) + 1}`,\n    };\n  });\n}\n\nexport function InfiniteMasonryPreview() {\n  const [items, setItems] = useState(() => createItems(0, 12));\n  const [loading, setLoading] = useState(false);\n  const hasMore = items.length < MAX_ITEMS;\n\n  const loadMore = useCallback(async () => {\n    if (loading || !hasMore) return;\n    setLoading(true);\n\n    await new Promise((resolve) => setTimeout(resolve, 650));\n    setItems((current) => {\n      const count = Math.min(PAGE_SIZE, MAX_ITEMS - current.length);\n      return count > 0\n        ? [...current, ...createItems(current.length, count)]\n        : current;\n    });\n    setLoading(false);\n  }, [hasMore, loading]);\n\n  return (\n    <div className=\"w-full max-w-5xl\">\n      <div className=\"mb-3 flex items-end justify-between gap-4 px-1\">\n        <div>\n          <p className=\"text-sm font-semibold text-foreground\">Visual index</p>\n          <p className=\"mt-0.5 text-xs text-muted-foreground\">\n            Scroll to load the next page\n          </p>\n        </div>\n        <p className=\"font-mono text-xs tabular-nums text-muted-foreground\">\n          {items.length} items\n        </p>\n      </div>\n\n      <InfiniteMasonry\n        items={items}\n        getItemKey={(item) => item.id}\n        renderItem={(item) => (\n          <figure className=\"overflow-hidden rounded-2xl border border-border bg-card\">\n            <div\n              className=\"relative w-full overflow-hidden\"\n              style={{ height: item.imageHeight }}\n            >\n              <Image\n                src={item.image}\n                alt={item.title}\n                fill\n                sizes=\"(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw\"\n                className=\"object-cover\"\n              />\n            </div>\n            <figcaption className=\"flex items-start justify-between gap-3 p-3\">\n              <p className=\"text-sm font-medium text-foreground\">\n                {item.title}\n              </p>\n              <p className=\"shrink-0 text-[10px] uppercase tracking-wider text-muted-foreground\">\n                {item.category}\n              </p>\n            </figcaption>\n          </figure>\n        )}\n        onLoadMore={loadMore}\n        hasMore={hasMore}\n        loading={loading}\n        estimateSize={(item) => item.imageHeight + 66}\n        ariaLabel=\"Visual inspiration gallery\"\n        className=\"h-[34rem]\"\n      />\n    </div>\n  );\n}\n"}]}