Infinite Masonry
Responsive virtualized masonry that measures variable-height cards and loads more data as the user nears the end.
Preview
Visual index
Scroll to load the next page
12 items
"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>
);
}
"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.
shadcn init? You are set. Theme setupInstall dependencies
npm i @tanstack/react-virtual clsx lucide-react motion tailwind-mergeAdd util files
// 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;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
"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
itemsreadonly T[]—getItemKey(item: T, index: number) => InfiniteMasonryKey—renderItem(item: T, index: number) => ReactNode—onLoadMore() => void | Promise<void>—hasMoreboolean—loading?booleanfalseerror?ReactNode—onRetry?(() => void)—estimateSize?((item: T, index: number) => number)() => 240renderLoadingItem?((index: number) => ReactNode)(index) => <DefaultLoadingItem index={index} />emptyState?ReactNode<DefaultEmptyState />endState?ReactNode—minColumnWidth?number208maxColumns?number4gap?number12overscan?number4prefetch?number3animateItems?booleantrueariaLabel?stringInfinite masonry feedclassName?string—contentClassName?string—itemClassName?string—Related components
Notification Stack
Compact notification cards that spring from a stacked summary into a readable list on hover, focus or tap.
Expandable Tabs
Icon tab bar where the active tab expands to a labelled pill, with a panel above that morphs height and slides content direction-aware on switch.
Bloom Menu
A button that morphs open into a menu and blooms iris-out from the center, the grid revealing in every direction with radially staggered items.
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.
Updated