Skip to content

Instantly share code, notes, and snippets.

@rogulia
Last active April 14, 2026 14:32
Show Gist options
  • Select an option

  • Save rogulia/bd63ba4dc672934bc5c9411144de9282 to your computer and use it in GitHub Desktop.

Select an option

Save rogulia/bd63ba4dc672934bc5c9411144de9282 to your computer and use it in GitHub Desktop.
MasonryGrid — React masonry layout with correct left-to-right reading order. Zero dependencies, TypeScript, Next.js SSR compatible.

MasonryGrid — Correct Reading Order

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.

Masonry grid: column-count vs round-robin

The Problem

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  │
└────┴────┴────┘         └────┴────┴────┘

Why the Popular Reorder Trick Fails

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

What about display: masonry / display: grid-lanes?

/* ❌ 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.

The Correct Approach

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.

Usage

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>

Props

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

Breakpoints

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 columns

Implementation Notes

useIsomorphicLayoutEffectuseLayoutEffect 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).

Requirements

  • React 18+
  • Tailwind CSS (for gap classes — or replace with inline styles)
  • Next.js 13+ App Router compatible ("use client" directive included)

Full write-up

React Masonry Layout: Why the Popular Reorder Trick Fails

"use client";
import React, { useEffect, useLayoutEffect, useState, ReactNode } from "react";
// useLayoutEffect fires before paint on client; useEffect on server (SSR no-op)
const useIsomorphicLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect;
interface Breakpoints {
default: number;
sm?: number;
md?: number;
lg?: number;
}
function getColumns(bp: Breakpoints): number {
const w = window.innerWidth;
if (bp.lg && w >= 1024) return bp.lg;
if (bp.md && w >= 768) return bp.md;
if (bp.sm && w >= 640) return bp.sm;
return bp.default;
}
function useColumns(bp: Breakpoints): number {
const [cols, setCols] = useState(bp.default);
useIsomorphicLayoutEffect(() => {
setCols(getColumns(bp));
function update() {
setCols(getColumns(bp));
}
window.addEventListener("resize", update);
return () => window.removeEventListener("resize", update);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
return cols;
}
interface MasonryGridProps {
children: ReactNode;
gap?: string;
breakpoints: Breakpoints;
}
export function MasonryGrid({ children, gap = "gap-6", breakpoints }: MasonryGridProps) {
const cols = useColumns(breakpoints);
const childArray = React.Children.toArray(children);
const columns: ReactNode[][] = Array.from({ length: cols }, () => []);
childArray.forEach((child, i) => columns[i % cols].push(child));
return (
<div className={`flex ${gap}`}>
{columns.map((col, colIdx) => (
<div key={colIdx} className={`flex-1 flex flex-col ${gap}`}>
{col}
</div>
))}
</div>
);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment