Infinite Masonry
NewResponsive virtualized masonry that measures variable-height cards and loads more data as the user nears the end.
Updated
Visual index
Scroll to load the next page
12 items
TSXcomponents/previews/blocks/infinite-masonry.preview.tsx
"use client";
import Image from "next/image";
import { useCallback, useState } from "react";
import { InfiniteMasonry } from "@/components/motion/infinite-masonry";
type GalleryItem = {
id: string;
title: string;
category: string;
image: string;
imageHeight: number;
};
const PAGE_SIZE = 6;
const MAX_ITEMS = 42;
const gallerySource = [
{
title: "Soft geometry",
category: "Architecture",
image:
"https://images.unsplash.com/photo-1511818966892-d7d671e672a2?auto=format&fit=crop&w=720&q=80",
imageHeight: 260,
},
{
title: "Open horizon",
category: "Landscape",
image:
"https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?auto=format&fit=crop&w=720&q=80",
imageHeight: 190,
},
{
title: "Working rhythm",
category: "Workspace",
image:
"https://images.unsplash.com/photo-1497366754035-f200968a6e72?auto=format&fit=crop&w=720&q=80",
imageHeight: 230,
},
{
title: "Shared table",
category: "Studio",
image:
"https://images.unsplash.com/photo-1524758631624-e2822e304c36?auto=format&fit=crop&w=720&q=80",
imageHeight: 300,
},
{
title: "In session",
category: "People",
image:
"https://images.unsplash.com/photo-1521737711867-e3b97375f902?auto=format&fit=crop&w=720&q=80",
imageHeight: 210,
},
{
title: "After hours",
category: "Office",
image:
"https://images.unsplash.com/photo-1504384308090-c894fdcc538d?auto=format&fit=crop&w=720&q=80",
imageHeight: 280,
},
] as const;
function createItems(start: number, count: number): GalleryItem[] {
return Array.from({ length: count }, (_, offset) => {
const index = start + offset;
const source = gallerySource[index % gallerySource.length];
return {
...source,
id: `gallery-${index}`,
title: `${source.title} ${Math.floor(index / gallerySource.length) + 1}`,
};
});
}
export function InfiniteMasonryPreview() {
const [items, setItems] = useState(() => createItems(0, 12));
const [loading, setLoading] = useState(false);
const hasMore = items.length < MAX_ITEMS;
const loadMore = useCallback(async () => {
if (loading || !hasMore) return;
setLoading(true);
await new Promise((resolve) => setTimeout(resolve, 650));
setItems((current) => {
const count = Math.min(PAGE_SIZE, MAX_ITEMS - current.length);
return count > 0
? [...current, ...createItems(current.length, count)]
: current;
});
setLoading(false);
}, [hasMore, loading]);
return (
<div className="w-full max-w-5xl">
<div className="mb-3 flex items-end justify-between gap-4 px-1">
<div>
<p className="text-sm font-semibold text-foreground">Visual index</p>
<p className="mt-0.5 text-xs text-muted-foreground">
Scroll to load the next page
</p>
</div>
<p className="font-mono text-xs tabular-nums text-muted-foreground">
{items.length} items
</p>
</div>
<InfiniteMasonry
items={items}
getItemKey={(item) => item.id}
renderItem={(item) => (
<figure className="overflow-hidden rounded-2xl border border-border bg-card">
<div
className="relative w-full overflow-hidden"
style={{ height: item.imageHeight }}
>
<Image
src={item.image}
alt={item.title}
fill
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw"
className="object-cover"
/>
</div>
<figcaption className="flex items-start justify-between gap-3 p-3">
<p className="text-sm font-medium text-foreground">
{item.title}
</p>
<p className="shrink-0 text-[10px] uppercase tracking-wider text-muted-foreground">
{item.category}
</p>
</figcaption>
</figure>
)}
onLoadMore={loadMore}
hasMore={hasMore}
loading={loading}
estimateSize={(item) => item.imageHeight + 66}
ariaLabel="Visual inspiration gallery"
className="h-[34rem]"
/>
</div>
);
}
TSXcomponents/motion/infinite-masonry.tsx
"use client";
// beui.dev/components/blocks/infinite-masonry
import { useVirtualizer } from "@tanstack/react-virtual";
import { AlertCircle, Inbox } from "lucide-react";
import { motion, useReducedMotion } from "motion/react";
import {
useEffect,
useRef,
useState,
type MutableRefObject,
type ReactNode,
} from "react";
import { EASE_OUT, SPRING_PANEL } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type InfiniteMasonryKey = string | number | bigint;
export interface InfiniteMasonryProps<T> {
items: readonly T[];
getItemKey: (item: T, index: number) => InfiniteMasonryKey;
renderItem: (item: T, index: number) => ReactNode;
onLoadMore: () => void | Promise<void>;
hasMore: boolean;
loading?: boolean;
error?: ReactNode;
onRetry?: () => void;
estimateSize?: (item: T, index: number) => number;
renderLoadingItem?: (index: number) => ReactNode;
emptyState?: ReactNode;
endState?: ReactNode;
minColumnWidth?: number;
maxColumns?: number;
gap?: number;
overscan?: number;
prefetch?: number;
animateItems?: boolean;
ariaLabel?: string;
className?: string;
contentClassName?: string;
itemClassName?: string;
}
type MasonryMetrics = {
columns: number;
width: number;
};
function useMasonryMetrics({
elementRef,
gap,
maxColumns,
minColumnWidth,
}: {
elementRef: React.RefObject<HTMLDivElement | null>;
gap: number;
maxColumns: number;
minColumnWidth: number;
}) {
const [metrics, setMetrics] = useState<MasonryMetrics>({
columns: 1,
width: 0,
});
useEffect(() => {
const element = elementRef.current;
if (!element) return;
const update = (width: number) => {
const columns = Math.min(
maxColumns,
Math.max(1, Math.floor((width + gap) / (minColumnWidth + gap))),
);
setMetrics((current) =>
current.columns === columns && current.width === width
? current
: { columns, width },
);
};
update(element.getBoundingClientRect().width);
const observer = new ResizeObserver(([entry]) => {
update(entry.contentRect.width);
});
observer.observe(element);
return () => observer.disconnect();
}, [elementRef, gap, maxColumns, minColumnWidth]);
return metrics;
}
function DefaultLoadingItem({ index }: { index: number }) {
return (
<div
aria-hidden="true"
className="animate-pulse rounded-2xl border border-border bg-card p-3"
style={{ minHeight: 144 + (index % 3) * 36 }}
>
<div className="h-3 w-2/3 rounded-full bg-muted" />
<div className="mt-3 h-2 w-full rounded-full bg-muted" />
<div className="mt-2 h-2 w-4/5 rounded-full bg-muted" />
</div>
);
}
function DefaultEmptyState() {
return (
<div className="flex h-full min-h-64 flex-col items-center justify-center px-6 text-center">
<Inbox className="size-8 text-muted-foreground" aria-hidden="true" />
<p className="mt-3 text-sm font-medium text-foreground">No items yet</p>
<p className="mt-1 max-w-xs text-xs leading-5 text-muted-foreground">
New items will appear here when they become available.
</p>
</div>
);
}
function MasonryItemReveal({
itemKey,
lane,
revealedKeys,
animate,
children,
}: {
itemKey: InfiniteMasonryKey;
lane: number;
revealedKeys: MutableRefObject<Set<InfiniteMasonryKey>>;
animate: boolean;
children: ReactNode;
}) {
const [shouldReveal] = useState(
() => animate && !revealedKeys.current.has(itemKey),
);
useEffect(() => {
revealedKeys.current.add(itemKey);
}, [itemKey, revealedKeys]);
const delay = Math.min(lane, 3) * 0.04;
return (
<motion.div
initial={shouldReveal ? { opacity: 0, y: 12 } : false}
animate={{ opacity: 1, y: 0 }}
transition={{
y: { ...SPRING_PANEL, delay },
opacity: { duration: 0.2, ease: EASE_OUT, delay },
}}
>
{children}
</motion.div>
);
}
export function InfiniteMasonry<T>({
items,
getItemKey,
renderItem,
onLoadMore,
hasMore,
loading = false,
error,
onRetry,
estimateSize = () => 240,
renderLoadingItem = (index) => <DefaultLoadingItem index={index} />,
emptyState = <DefaultEmptyState />,
endState,
minColumnWidth = 208,
maxColumns = 4,
gap = 12,
overscan = 4,
prefetch = 3,
animateItems = true,
ariaLabel = "Infinite masonry feed",
className,
contentClassName,
itemClassName,
}: InfiniteMasonryProps<T>) {
const reduceMotion = useReducedMotion();
const scrollRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const loadMoreRef = useRef(onLoadMore);
const loadPendingRef = useRef(false);
const initialItemCountRef = useRef(items.length);
const revealedKeysRef = useRef(new Set<InfiniteMasonryKey>());
const { columns, width } = useMasonryMetrics({
elementRef: contentRef,
gap,
maxColumns,
minColumnWidth,
});
useEffect(() => {
loadMoreRef.current = onLoadMore;
}, [onLoadMore]);
useEffect(() => {
if (!loading) loadPendingRef.current = false;
}, [loading]);
const hasError = error !== undefined && error !== null;
const tailCount = hasError ? 1 : loading ? columns : 0;
const virtualizer = useVirtualizer({
count: items.length + tailCount,
getScrollElement: () => scrollRef.current,
getItemKey: (index) =>
index < items.length
? getItemKey(items[index], index)
: `masonry-tail-${index - items.length}`,
estimateSize: (index) =>
index < items.length
? estimateSize(items[index], index)
: 144 + ((index - items.length) % 3) * 36,
gap,
lanes: columns,
overscan: overscan * columns,
});
const virtualItems = virtualizer.getVirtualItems();
const viewportEnd =
(virtualizer.scrollOffset ?? 0) + (virtualizer.scrollRect?.height ?? 0);
const lastVisibleIndex = virtualItems.reduce(
(lastIndex, item) =>
item.start < viewportEnd ? Math.max(lastIndex, item.index) : lastIndex,
-1,
);
useEffect(() => {
if (
hasError ||
loading ||
!hasMore ||
loadPendingRef.current ||
lastVisibleIndex < Math.max(0, items.length - prefetch)
) {
return;
}
loadPendingRef.current = true;
void Promise.resolve(loadMoreRef.current()).finally(() => {
loadPendingRef.current = false;
});
}, [hasError, hasMore, items.length, lastVisibleIndex, loading, prefetch]);
if (items.length === 0 && !hasMore && !loading && !hasError) {
return (
<section
aria-label={ariaLabel}
className={cn(
"w-full overflow-hidden rounded-3xl border border-border bg-background",
className,
)}
>
{emptyState}
</section>
);
}
const columnWidth =
columns > 0 ? Math.max(0, (width - gap * (columns - 1)) / columns) : 0;
return (
<section
ref={scrollRef}
aria-label={ariaLabel}
aria-busy={loading}
className={cn(
"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]",
className,
)}
>
<div
ref={contentRef}
className={cn("relative w-full", contentClassName)}
style={{ height: virtualizer.getTotalSize() }}
>
{virtualItems.map((virtualItem) => {
const isTail = virtualItem.index >= items.length;
const tailIndex = virtualItem.index - items.length;
return (
<div
key={virtualItem.key}
ref={virtualizer.measureElement}
data-index={virtualItem.index}
className={cn(
"absolute left-0 top-0 will-change-transform",
!isTail && itemClassName,
)}
style={{
width: columnWidth,
transform: `translate3d(${virtualItem.lane * (columnWidth + gap)}px, ${virtualItem.start}px, 0)`,
}}
>
{isTail ? (
hasError ? (
<div className="flex min-h-36 flex-col items-start justify-center rounded-2xl border border-destructive/20 bg-destructive/5 p-4">
<div className="flex items-center gap-2 text-destructive">
<AlertCircle className="size-4" aria-hidden="true" />
<p className="text-sm font-medium">Couldn't load more</p>
</div>
<div className="mt-2 text-xs leading-5 text-muted-foreground">
{error}
</div>
{onRetry ? (
<button
type="button"
onClick={onRetry}
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"
>
Try again
</button>
) : null}
</div>
) : (
renderLoadingItem(tailIndex)
)
) : (
<MasonryItemReveal
itemKey={virtualItem.key}
lane={virtualItem.lane}
revealedKeys={revealedKeysRef}
animate={
animateItems &&
!reduceMotion &&
virtualItem.index >= initialItemCountRef.current
}
>
{renderItem(items[virtualItem.index], virtualItem.index)}
</MasonryItemReveal>
)}
</div>
);
})}
</div>
{!hasMore && items.length > 0 && endState ? (
<div className="py-4 text-center text-xs text-muted-foreground">
{endState}
</div>
) : null}
<span className="sr-only" aria-live="polite">
{loading ? "Loading more items" : null}
</span>
</section>
);
}
Install
Add it with the shadcn CLI, or copy the source manually.
$ bunx --bun shadcn add @beui/infinite-masonry
Needs the theme tokens once. Already ran
shadcn init? You are set. Theme setupInstall dependencies
npm i @tanstack/react-virtual clsx lucide-react motion tailwind-mergeAdd util files
TSXlib/ease.ts
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
TSXlib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
TSXcomponents/motion/infinite-masonry.tsx
"use client";
// beui.dev/components/blocks/infinite-masonry
import { useVirtualizer } from "@tanstack/react-virtual";
import { AlertCircle, Inbox } from "lucide-react";
import { motion, useReducedMotion } from "motion/react";
import {
useEffect,
useRef,
useState,
type MutableRefObject,
type ReactNode,
} from "react";
import { EASE_OUT, SPRING_PANEL } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type InfiniteMasonryKey = string | number | bigint;
export interface InfiniteMasonryProps<T> {
items: readonly T[];
getItemKey: (item: T, index: number) => InfiniteMasonryKey;
renderItem: (item: T, index: number) => ReactNode;
onLoadMore: () => void | Promise<void>;
hasMore: boolean;
loading?: boolean;
error?: ReactNode;
onRetry?: () => void;
estimateSize?: (item: T, index: number) => number;
renderLoadingItem?: (index: number) => ReactNode;
emptyState?: ReactNode;
endState?: ReactNode;
minColumnWidth?: number;
maxColumns?: number;
gap?: number;
overscan?: number;
prefetch?: number;
animateItems?: boolean;
ariaLabel?: string;
className?: string;
contentClassName?: string;
itemClassName?: string;
}
type MasonryMetrics = {
columns: number;
width: number;
};
function useMasonryMetrics({
elementRef,
gap,
maxColumns,
minColumnWidth,
}: {
elementRef: React.RefObject<HTMLDivElement | null>;
gap: number;
maxColumns: number;
minColumnWidth: number;
}) {
const [metrics, setMetrics] = useState<MasonryMetrics>({
columns: 1,
width: 0,
});
useEffect(() => {
const element = elementRef.current;
if (!element) return;
const update = (width: number) => {
const columns = Math.min(
maxColumns,
Math.max(1, Math.floor((width + gap) / (minColumnWidth + gap))),
);
setMetrics((current) =>
current.columns === columns && current.width === width
? current
: { columns, width },
);
};
update(element.getBoundingClientRect().width);
const observer = new ResizeObserver(([entry]) => {
update(entry.contentRect.width);
});
observer.observe(element);
return () => observer.disconnect();
}, [elementRef, gap, maxColumns, minColumnWidth]);
return metrics;
}
function DefaultLoadingItem({ index }: { index: number }) {
return (
<div
aria-hidden="true"
className="animate-pulse rounded-2xl border border-border bg-card p-3"
style={{ minHeight: 144 + (index % 3) * 36 }}
>
<div className="h-3 w-2/3 rounded-full bg-muted" />
<div className="mt-3 h-2 w-full rounded-full bg-muted" />
<div className="mt-2 h-2 w-4/5 rounded-full bg-muted" />
</div>
);
}
function DefaultEmptyState() {
return (
<div className="flex h-full min-h-64 flex-col items-center justify-center px-6 text-center">
<Inbox className="size-8 text-muted-foreground" aria-hidden="true" />
<p className="mt-3 text-sm font-medium text-foreground">No items yet</p>
<p className="mt-1 max-w-xs text-xs leading-5 text-muted-foreground">
New items will appear here when they become available.
</p>
</div>
);
}
function MasonryItemReveal({
itemKey,
lane,
revealedKeys,
animate,
children,
}: {
itemKey: InfiniteMasonryKey;
lane: number;
revealedKeys: MutableRefObject<Set<InfiniteMasonryKey>>;
animate: boolean;
children: ReactNode;
}) {
const [shouldReveal] = useState(
() => animate && !revealedKeys.current.has(itemKey),
);
useEffect(() => {
revealedKeys.current.add(itemKey);
}, [itemKey, revealedKeys]);
const delay = Math.min(lane, 3) * 0.04;
return (
<motion.div
initial={shouldReveal ? { opacity: 0, y: 12 } : false}
animate={{ opacity: 1, y: 0 }}
transition={{
y: { ...SPRING_PANEL, delay },
opacity: { duration: 0.2, ease: EASE_OUT, delay },
}}
>
{children}
</motion.div>
);
}
export function InfiniteMasonry<T>({
items,
getItemKey,
renderItem,
onLoadMore,
hasMore,
loading = false,
error,
onRetry,
estimateSize = () => 240,
renderLoadingItem = (index) => <DefaultLoadingItem index={index} />,
emptyState = <DefaultEmptyState />,
endState,
minColumnWidth = 208,
maxColumns = 4,
gap = 12,
overscan = 4,
prefetch = 3,
animateItems = true,
ariaLabel = "Infinite masonry feed",
className,
contentClassName,
itemClassName,
}: InfiniteMasonryProps<T>) {
const reduceMotion = useReducedMotion();
const scrollRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const loadMoreRef = useRef(onLoadMore);
const loadPendingRef = useRef(false);
const initialItemCountRef = useRef(items.length);
const revealedKeysRef = useRef(new Set<InfiniteMasonryKey>());
const { columns, width } = useMasonryMetrics({
elementRef: contentRef,
gap,
maxColumns,
minColumnWidth,
});
useEffect(() => {
loadMoreRef.current = onLoadMore;
}, [onLoadMore]);
useEffect(() => {
if (!loading) loadPendingRef.current = false;
}, [loading]);
const hasError = error !== undefined && error !== null;
const tailCount = hasError ? 1 : loading ? columns : 0;
const virtualizer = useVirtualizer({
count: items.length + tailCount,
getScrollElement: () => scrollRef.current,
getItemKey: (index) =>
index < items.length
? getItemKey(items[index], index)
: `masonry-tail-${index - items.length}`,
estimateSize: (index) =>
index < items.length
? estimateSize(items[index], index)
: 144 + ((index - items.length) % 3) * 36,
gap,
lanes: columns,
overscan: overscan * columns,
});
const virtualItems = virtualizer.getVirtualItems();
const viewportEnd =
(virtualizer.scrollOffset ?? 0) + (virtualizer.scrollRect?.height ?? 0);
const lastVisibleIndex = virtualItems.reduce(
(lastIndex, item) =>
item.start < viewportEnd ? Math.max(lastIndex, item.index) : lastIndex,
-1,
);
useEffect(() => {
if (
hasError ||
loading ||
!hasMore ||
loadPendingRef.current ||
lastVisibleIndex < Math.max(0, items.length - prefetch)
) {
return;
}
loadPendingRef.current = true;
void Promise.resolve(loadMoreRef.current()).finally(() => {
loadPendingRef.current = false;
});
}, [hasError, hasMore, items.length, lastVisibleIndex, loading, prefetch]);
if (items.length === 0 && !hasMore && !loading && !hasError) {
return (
<section
aria-label={ariaLabel}
className={cn(
"w-full overflow-hidden rounded-3xl border border-border bg-background",
className,
)}
>
{emptyState}
</section>
);
}
const columnWidth =
columns > 0 ? Math.max(0, (width - gap * (columns - 1)) / columns) : 0;
return (
<section
ref={scrollRef}
aria-label={ariaLabel}
aria-busy={loading}
className={cn(
"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]",
className,
)}
>
<div
ref={contentRef}
className={cn("relative w-full", contentClassName)}
style={{ height: virtualizer.getTotalSize() }}
>
{virtualItems.map((virtualItem) => {
const isTail = virtualItem.index >= items.length;
const tailIndex = virtualItem.index - items.length;
return (
<div
key={virtualItem.key}
ref={virtualizer.measureElement}
data-index={virtualItem.index}
className={cn(
"absolute left-0 top-0 will-change-transform",
!isTail && itemClassName,
)}
style={{
width: columnWidth,
transform: `translate3d(${virtualItem.lane * (columnWidth + gap)}px, ${virtualItem.start}px, 0)`,
}}
>
{isTail ? (
hasError ? (
<div className="flex min-h-36 flex-col items-start justify-center rounded-2xl border border-destructive/20 bg-destructive/5 p-4">
<div className="flex items-center gap-2 text-destructive">
<AlertCircle className="size-4" aria-hidden="true" />
<p className="text-sm font-medium">Couldn't load more</p>
</div>
<div className="mt-2 text-xs leading-5 text-muted-foreground">
{error}
</div>
{onRetry ? (
<button
type="button"
onClick={onRetry}
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"
>
Try again
</button>
) : null}
</div>
) : (
renderLoadingItem(tailIndex)
)
) : (
<MasonryItemReveal
itemKey={virtualItem.key}
lane={virtualItem.lane}
revealedKeys={revealedKeysRef}
animate={
animateItems &&
!reduceMotion &&
virtualItem.index >= initialItemCountRef.current
}
>
{renderItem(items[virtualItem.index], virtualItem.index)}
</MasonryItemReveal>
)}
</div>
);
})}
</div>
{!hasMore && items.length > 0 && endState ? (
<div className="py-4 text-center text-xs text-muted-foreground">
{endState}
</div>
) : null}
<span className="sr-only" aria-live="polite">
{loading ? "Loading more items" : null}
</span>
</section>
);
}
API Reference
items{}—getItemKey(item: T, index: number) => InfiniteMasonryKey—renderItem(item: T, index: number) => ReactNode—onLoadMore() => any—hasMoreboolean—loading?booleanfalseerror?any—onRetry?(() => void)—estimateSize?((item: T, index: number) => number)() => 240renderLoadingItem?((index: number) => ReactNode)(index) => <DefaultLoadingItem index={index} />emptyState?any<DefaultEmptyState />endState?any—minColumnWidth?number208maxColumns?number4gap?number12overscan?number4prefetch?number3animateItems?booleantrueariaLabel?stringInfinite masonry feedclassName?string—contentClassName?string—itemClassName?string—Keep in mind
Some components on this site are inspired by or recreated from existing work across the web. I'm not here to take credit; just to learn, experiment, and sometimes push things a bit further. If something looks familiar and I forgot to mention you, reach out and I'll fix that right away.