React masonry grid with zero dependencies. TypeScript and Next.js SSR compatible. Fixes CSS column-count left-to-right bug. Round-robin JS, not CSS column distribution.
CSS column-count is a newspaper-layout primitive — it fills columns top-to-bottom, not left-to-right:
Items: [1, 2, 3, 4, 5, 6] column-count: 3
What CSS does: What you want:
┌────┬────┬────┐ ┌────┬────┬────┐
│ 1 │ 3 │ 5 │ │ 1 │ 2 │ 3 │
│ 2 │ 4 │ 6 │ │ 4 │ 5 │ 6 │
└────┴────┴────┘ └────┴────┴────┘
The widely shared fix (popularized by Jesse Korzan on Medium) reorders the array before render so CSS columns produces the correct visual order. It works — but only when all cards have identical heights.
// ❌ Breaks with variable-height cards
function reorder<T>(items: T[], cols: number): T[] {
const out: T[] = [];
for (let col = 0; col < cols; col++) {
for (let i = col; i < items.length; i += cols) {
out.push(items[i]);
}
}
return out;
}The algorithm assumes CSS puts exactly Math.ceil(N / cols) items per column. But CSS distributes by pixel height, not by count. A tall card in column 1 fills that column faster — column 2 starts earlier, takes more items, and the distribution no longer matches what the reorder algorithm predicted.
6 cards, heights: [300px, 100px, 150px, 200px, 100px, 250px]
Column target height: ~333px each
Algorithm expects: CSS actually produces:
Col 1: items 1, 4 Col 1: item 1 (300px) → full
Col 2: items 2, 5 Col 2: items 2, 3, 5 → 350px
Col 3: items 3, 6 Col 3: items 4, 6 → 450px
/* ❌ Not production-ready — ~0.02% global browser support (early 2026) */
.grid { display: grid-lanes; grid-template-columns: repeat(3, 1fr); }
.grid { display: masonry; masonry-template-tracks: repeat(3, 1fr); }display: masonry (original spec) and display: grid-lanes (current name after the CSS Working Group rename in 2024) are the same feature. Only Safari 26.4 ships it in stable. Chrome and Firefox are behind flags. Not usable in production.
Stop using column-count entirely. Create N separate <div> containers — one per column — and distribute items into them with round-robin JS assignment:
item 0 → col 0
item 1 → col 1
item 2 → col 2
item 3 → col 0 ← back to col 0
When JS controls which column each item goes into, CSS has no say in the distribution. Card heights don't matter. Row 1 always contains items [0, 1, 2], row 2 always contains [3, 4, 5].
Trade-off: columns won't auto-balance by height — each column gets exactly Math.ceil(N / cols) items regardless of card height. For most grids (portfolio, blog, product listings) consistent reading order matters more than balanced column heights.
import { MasonryGrid } from "./MasonryGrid";
<MasonryGrid gap="gap-6" breakpoints={{ default: 1, md: 2, lg: 3 }}>
{posts.map((post) => (
<PostCard key={post.slug} post={post} />
))}
</MasonryGrid>| Prop | Type | Default | Description |
|---|---|---|---|
children |
ReactNode |
— | Cards to distribute into columns |
breakpoints |
Breakpoints |
— | Column count per viewport width |
gap |
string |
"gap-6" |
Tailwind gap class, applied between columns and between items |
interface Breakpoints {
default: number; // mobile (< sm)
sm?: number; // ≥ 640px
md?: number; // ≥ 768px
lg?: number; // ≥ 1024px
}Examples:
breakpoints={{ default: 1, md: 2, lg: 3 }} // 1 → 2 → 3 columns
breakpoints={{ default: 1, sm: 2 }} // 1 → 2 columnsuseIsomorphicLayoutEffect — useLayoutEffect throws a warning in SSR (Next.js). The isomorphic pattern uses useEffect on the server (no-op) and useLayoutEffect on the client, so the column count is applied before first paint — no flash of single-column layout.
bp.default as SSR value — the server always renders default columns (typically 1). On hydration, useIsomorphicLayoutEffect fires synchronously and updates to the correct column count. Zero layout shift for most visitors.
flex-1 on column divs — columns share available width equally without needing width: calc(100% / cols).
Gap — the same Tailwind gap class applies both between columns (flex row) and between items within a column (flex column).
- React 18+
- Tailwind CSS (for gap classes — or replace with inline styles)
- Next.js 13+ App Router compatible (
"use client"directive included)
