Masonry
A masonry layout that balances items across equal-width columns by measured height, with responsive column counts
Installation
Usage
Basic Usage
Items are distributed across equal-width columns with a greedy shortest-column strategy measured from the real DOM, so columns stay visually balanced. The column count responds to the container width by default (1 / sm:2 / lg:3 / xl:4).
Sunrise
Ocean
Desert
Glacier
Meadow
Mountains
Forest
Canyon
Volcano
"use client";import { Masonry } from "@/ui";const cards = [ { title: "Sunrise", height: "h-24", tone: "bg-primary/10" }, { title: "Mountains", height: "h-44", tone: "bg-accent/20" }, { title: "Ocean", height: "h-32", tone: "bg-primary/15" }, { title: "Forest", height: "h-52", tone: "bg-accent/10" }, { title: "Desert", height: "h-28", tone: "bg-primary/20" }, { title: "Canyon", height: "h-40", tone: "bg-accent/15" }, { title: "Glacier", height: "h-36", tone: "bg-primary/10" }, { title: "Volcano", height: "h-48", tone: "bg-accent/25" }, { title: "Meadow", height: "h-28", tone: "bg-primary/15" },];export default function MasonryBasicDemo() { return ( <div className="w-full max-w-md"> <Masonry items={cards} columns={{ base: 2, md: 3 }} getKey={(card) => card.title} renderItem={(card) => ( <div className={`rounded-lg border p-3 ${card.height} ${card.tone}`}> <p className="text-sm font-medium">{card.title}</p> </div> )} /> </div> );}Custom
Pass a fixed columns count and pair the layout with useLoadMore to grow the list while scrolling an inner container. Prefix items keep their column placement as the list grows, so existing content never jumps.
8 of 24 loaded
"use client";
import * as React from "react";
import { Button, Masonry, useLoadMore } from "@/ui";
interface Photo {
id: number;
title: string;
height: number;
tone: string;
}
const tones = ["bg-primary/10", "bg-accent/15", "bg-primary/20", "bg-accent/10"];
const allPhotos: Photo[] = Array.from({ length: 24 }, (_, i) => ({
id: i + 1,
title: `Photo ${String(i + 1).padStart(2, "0")}`,
height: 72 + ((i * 37) % 96),
tone: tones[i % tones.length],
}));
export default function MasonryCustomDemo() {
const containerRef = React.useRef<HTMLDivElement>(null);
const { visibleCount, hasMore, loadMore, sentinelRef } = useLoadMore({
total: allPhotos.length,
pageSize: 8,
root: containerRef,
rootMargin: "120px",
});
const visible = allPhotos.slice(0, visibleCount);
return (
<div className="flex w-full max-w-md flex-col gap-2">
<div
ref={containerRef}
className="h-72 overflow-y-auto rounded-lg border p-2"
tabIndex={0}
aria-label="Photo gallery"
data-testid="scroll-root"
>
<Masonry
items={visible}
columns={2}
className="gap-2"
getKey={(photo) => photo.id}
renderItem={(photo) => (
<div
className={`flex items-end rounded-md border p-2 ${photo.tone}`}
style={{ height: photo.height }}
>
<span className="text-xs font-medium">{photo.title}</span>
</div>
)}
/>
{hasMore && <div ref={sentinelRef} aria-hidden className="h-px" />}
</div>
<div className="flex items-center justify-between gap-2">
<p className="text-xs text-muted-foreground">
{visibleCount} of {allPhotos.length} loaded
</p>
{hasMore && (
<Button variant="outline" onClick={loadMore}>
Load more
</Button>
)}
</div>
</div>
);
}
Nesting
A Masonry rendered inside another Masonry (anywhere in the React tree) automatically degrades to a static CSS multi-columns layout: no measurement and no ResizeObserver, so the two levels cannot feed each other resize loops. The nested layout trades height balancing away — columns fill in source order (DOM order is preserved), columns breakpoints resolve via container queries, and counts are capped at 6. A dev-only console.warn announces the degradation. The composed parts (Masonry.Column / Masonry.Item) do no measuring and are unaffected.
API Reference
High-level Components
Masonry
A data-driven masonry layout. After each render it measures the rendered items and rebalances the distribution; the measurement converges (heights do not depend on the assignment because all columns share the same width). Async content that grows after mount (images, lazy demos) is not re-measured automatically — give such items a min-height close to their final size. Nested inside another Masonry it renders statically instead (see Nesting).
| Prop | Type | Default | Description |
|---|---|---|---|
items | T[] | - | The data list |
renderItem | (item: T, index: number) => React.ReactNode | - | Renders one cell |
getKey | (item: T, index: number) => React.Key | index | Stable key per item; also drives the internal measurement cache |
columns | number | { base?; sm?; md?; lg?; xl?; 2xl? } | { base: 1, sm: 2, lg: 3, xl: 4 } | Fixed count, or counts resolved against the container width (Tailwind breakpoints: sm 640 / md 768 / lg 1024 / xl 1280 / 2xl 1536) |
className | ClassNameValue | built-in gap-4 (override via className) | Custom classes for the root — built-in gap-4, override to change spacing |
classNames | { column?; item? } — each ClassNameValue | - | Custom classes per part |
styles | { column?; item? } — each React.CSSProperties | - | Inline styles per part |
Statics
| Static | Type | Description |
|---|---|---|
Masonry.Column | MasonryColumn | A single column stack |
Masonry.Item | MasonryItem | A measured cell wrapper |
Composable Components
MasonryColumn
One equal-width column stack (flex min-w-0 flex-col).
| Prop | Type | Description |
|---|---|---|
className | ClassNameValue | Custom CSS class |
...props | React.ComponentProps<"div"> | Supports all native div props |
MasonryItem
The cell wrapper each item renders into (min-w-0), tagged with internal data-masonry-* attributes used for measuring.
| Prop | Type | Description |
|---|---|---|
className | ClassNameValue | Custom CSS class |
...props | React.ComponentProps<"div"> | Supports all native div props |