Layout Algorithms — Masonry
2026-08-10 · 10 min read
Most layouts assume uniform item heights. A grid of equal-height cards is trivial — CSS Grid handles it in one line. The hard case is variable-height content: photos with different aspect ratios, cards with text of different lengths, mixed media. A naive grid enforces a fixed row height and leaves gaps beneath shorter items. A Masonry layout eliminates those gaps by packing each item into whichever column is currently shortest, producing the staggered, offset look that Pinterest made familiar.
The algorithm
Masonry is a greedy bin-packing algorithm. The data structure is an array of column heights — one entry per column, initialized to zero. For each item, find the shortest column, place the item there, then update that column's height.
columnHeights = [0, 0, 0] ← 3 columns
Item A (height 200):
shortest column = 0 (all tied, pick leftmost)
place A at (x=0, y=0)
columnHeights = [200, 0, 0]
Item B (height 120):
shortest column = 1
place B at (x=col1, y=0)
columnHeights = [200, 120, 0]
Item C (height 300):
shortest column = 2
place C at (x=col2, y=0)
columnHeights = [200, 120, 300]
Item D (height 80):
shortest column = 1 (height 120 < 200 < 300)
place D at (x=col1, y=120)
columnHeights = [200, 200, 300]
Item E (height 150):
shortest column = 0 (tied 200/200, pick leftmost)
place E at (x=col0, y=200)
columnHeights = [350, 200, 300]
Each item's x position is columnIndex * (columnWidth + gap). Its y position is the current height of that column. After placing, the column height increases by itemHeight + gap. When columns tie, pick the leftmost — this produces a stable, deterministic ordering.
In code:
function masonry(items, columnCount, columnWidth, gap) {
const columnHeights = new Array(columnCount).fill(0);
const positions = [];
for (const item of items) {
const col = columnHeights.indexOf(Math.min(...columnHeights));
positions.push({
x: col * (columnWidth + gap),
y: columnHeights[col],
});
columnHeights[col] += item.height + gap;
}
return { positions, totalHeight: Math.max(...columnHeights) };
}
totalHeight is the tallest column — the container needs this height so the scrollbar is correct.
Why CSS can't do this (yet)
CSS Grid defines tracks upfront — you declare rows and columns, then items are placed into them. Masonry inverts this: items must be measured first, and their heights determine where subsequent items land. Grid has no way to say "place this item in whichever row is currently shortest." Its model is structure-first, placement-second; Masonry is placement-first.
Flexbox is 1D. flex-wrap creates rows but has no cross-axis awareness — items on one row don't know the heights of items on another. You can produce a column-style layout with flex-direction: column, but items flow top-to-bottom within each column independently, not across columns by shortest height.
CSS native masonry
CSS Grid Level 3 introduces grid-template-rows: masonry, which moves Masonry into the browser's layout engine. The syntax is straightforward:
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
grid-template-rows: masonry;
gap: 16px;
}
The browser handles column assignment, height tracking, and positioning natively — no JavaScript required. Browser support as of mid-2026: Safari 26 ships it; Chrome and Firefox are behind experimental flags. Until support is universal, a @supports progressive enhancement is the right approach:
@supports (grid-template-rows: masonry) {
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
grid-template-rows: masonry;
}
}
@supports not (grid-template-rows: masonry) {
/* Fall back to JS-driven absolute positioning */
.grid { position: relative; }
.grid > * { position: absolute; }
}
For browsers without native support, JavaScript takes over.
The two-pass layout problem
JavaScript Masonry requires two DOM passes that can't be combined:
- Measure — insert items into the DOM and read their heights
- Position — calculate (x, y) for each item and apply them
The problem is that reading layout properties (offsetHeight, getBoundingClientRect) after writing to the DOM forces the browser to flush all pending layout changes synchronously — this is layout thrashing. If you alternate reads and writes per item, the browser reflows on every iteration.
// Bad — thrashing on every item
items.forEach(item => {
const height = item.offsetHeight; // read → forces reflow
item.style.top = getNextY() + 'px'; // write → invalidates layout
});
// Good — batch reads, then batch writes
const heights = items.map(item => item.offsetHeight); // all reads
const positions = masonry(heights, columnCount, ...);
items.forEach((item, i) => { // all writes
item.style.transform =
`translate(${positions[i].x}px, ${positions[i].y}px)`;
});
Using transform instead of top/left is also important: transform doesn't affect layout flow, so it only triggers compositing — a much cheaper browser operation than a full reflow.
Dynamic images
Images load asynchronously after the initial layout pass. When an image finishes loading, its container jumps to its natural height — pushing every item below it out of position. The layout calculated before the images loaded is now wrong.
Three ways to handle this:
Declare dimensions upfront. Set width and height attributes on <img> tags. The browser uses these to reserve the correct space before the image file downloads, so the Masonry algorithm sees the correct height on the first pass.
<img src={photo.url} width={photo.width} height={photo.height} />
This works when your API returns image dimensions alongside the URL — which it should, since dimensions are read at upload time and stored in the database. The browser derives the aspect ratio from the attributes automatically; no CSS needed.
If you don't have per-image dimensions and want to enforce a uniform ratio across all images (e.g. every product card should be 4:3), aspect-ratio in CSS handles that:
.card img {
width: 100%;
aspect-ratio: 4 / 3;
object-fit: cover;
}
But this crops or distorts images that don't match that ratio — it's a design constraint, not a general solution.
Wait for all images. Delay the layout pass until every image in the container has finished loading. No re-runs needed, but first paint is delayed until the slowest image.
const images = container.querySelectorAll('img');
Promise.all(
Array.from(images).map(img =>
img.complete
? Promise.resolve()
: new Promise(resolve => { img.onload = img.onerror = resolve; })
)
).then(() => runLayout());
Re-run on each load. Attach an onload handler to each image and re-run the full layout when any image loads. Items appear progressively as images resolve, but the layout shifts multiple times. Acceptable for feeds where some layout churn is expected; bad for galleries where visual stability matters.
Responsive column count
Column count should be derived from the container's actual width, not the viewport — the grid might be inside a sidebar or a constrained panel.
function columnCount(containerWidth, minColumnWidth, gap) {
return Math.max(1, Math.floor((containerWidth + gap) / (minColumnWidth + gap)));
}
// 900px container, 280px min column, 16px gap:
// floor((900 + 16) / (280 + 16)) = floor(916 / 296) = 3 columns
Recompute when the container resizes using ResizeObserver — not window.resize, which fires for any viewport change regardless of whether the container actually changed width:
let currentCols = 0;
const ro = new ResizeObserver(entries => {
const width = entries[0].contentRect.width;
const cols = columnCount(width, MIN_COL_WIDTH, GAP);
if (cols !== currentCols) {
currentCols = cols;
runLayout();
}
});
ro.observe(container);
The if (cols !== currentCols) guard prevents re-running layout on every pixel of resize — only when the column count actually changes.
Virtualization
Virtualizing a flat list is straightforward: if every item is 50px tall and the viewport is 600px, scroll position ÷ 50 gives you the first visible item index. Masonry breaks this assumption.
Items in a Masonry layout are distributed across columns, and each item's vertical position depends on the cumulative height of all items placed before it in the same column. You can't compute the position of item 500 without knowing the heights of all 500 preceding items. There's no O(1) mapping from scroll position to item index.
The approach:
- Pre-measure in batches — render items off-screen (or in a hidden container), measure their heights, then remove them. Store the heights.
- Compute all positions — run the full Masonry algorithm over all measured heights to get (x, y) for every item. Cache this.
- Render only the visible window — from the cached positions, find items whose y falls within
[scrollTop - overscan, scrollTop + viewportHeight + overscan]. Render only those. - Set container height — to
max(columnHeights)so the scrollbar reflects the full content height. - Extend on scroll — when the user scrolls near the bottom of measured items, measure the next batch and extend the position cache.
function getVisibleItems(positions, itemHeights, scrollTop, viewportHeight, overscan = 200) {
const top = scrollTop - overscan;
const bottom = scrollTop + viewportHeight + overscan;
return positions
.map((pos, i) => ({ i, ...pos, bottom: pos.y + itemHeights[i] }))
.filter(({ y, bottom }) => bottom > top && y < bottom);
}
The constraint is memory: you must keep all measured heights and computed positions in memory, even for items far above the viewport. For a feed of 10,000 items this is manageable (a Float32Array of heights is ~40KB); for millions of items you'd need a more sophisticated approach.
Where this appears
Pinterest — the canonical Masonry product. Every pin is a photo with a variable-length caption and the staggered column layout is a core part of the brand.
Image galleries (Unsplash, Google Photos) — photos of arbitrary aspect ratios laid out without gaps. Both use known dimensions from the API to avoid the dynamic image problem.
Dashboard card layouts — analytics dashboards and Notion-style page grids where cards contain mixed content (charts, tables, text) of unpredictable heights. Fixed-row grids waste space; Masonry fills it.
E-commerce product grids — product cards with variable description lengths. Common on fashion and marketplace sites where product names and descriptions aren't standardized to a fixed line count.
Case study: Pinterest
Pinterest invented the modern masonry layout as a product. Inspecting their source reveals a three-layer rendering strategy designed to show a positioned layout as early as possible.
Layer 1: CSS pre-render. The SSR'd HTML contains an inline <style data-test-id="masonry-ssr-styles"> block that marks the masonry container as a CSS container and uses @container queries to float the first N pins into columns before any JS runs:
/* Default: hide everything until JS positions it */
.static { position: absolute !important; visibility: hidden !important; }
/* Reveal only the first column's worth as a float layout */
@container (min-width: 0px) and (max-width: 710.99px) {
.static:nth-child(-n+2) { position: static !important; float: left; width: calc(100% / 2); }
}
@container (min-width: 711px) and (max-width: 947.99px) {
.static:nth-child(-n+3) { position: static !important; float: left; width: calc(100% / 3); }
}
/* …continues through 6 columns at ≥1422px */
This gives users a rough column layout with zero JS — heights aren't staggered correctly, but the page isn't blank.
Layer 2: EarlyGridRenderScript. An inline <script> in <head> — named EarlyGridRenderScript in their source — fires before React hydrates. It queries .masonryContainer .static items and, if they have a rendered width, immediately runs the column-tracking algorithm and writes exact positions:
transform: translateX(${x}px) translateY(${y}px);
position: absolute !important;
visibility: visible !important;
If the items aren't in the DOM yet, it attaches a MutationObserver on document (childList: true, subtree: true) and triggers layout the moment they appear. It logs timing to window.earlyGridRenderStats. This is what makes Pinterest feel instant — precise masonry positions are applied before React's hydration pass.
Layer 3: React. Once window.__PWS_CLIENT_RENDER_INIT__ is set, the observer disconnects and React takes over for subsequent loads and interactions. The page is served with "renderMode": "shellReady" — nav, header, and board metadata are SSR'd; pins are client-rendered.
Each pin in the BoardResource API response includes "dominant_color": "#504A40" — a hex value Pinterest sets as each pin's background while the image loads, eliminating the white flash during lazy loading.