Design a Pinterest-like Masonry Grid UI System
A Pinterest-like Masonry Grid is an asynchronous, multi-column layout engine that places dynamic-height cards into the shortest available column, paired with infinite scrolling, 2D virtualization, aspect-ratio skeletons, and client memory management.
Why it matters in interviews
Visual & Interactive Explanation
Masonry Layout Approaches Comparison
CSS Columns / Grid
Native CSS column-count
JS Greedy Absolute Positioning
Industry Standard (Pinterest/Flickr)
Code Examples & Implementation
interface CardItem {
id: string;
aspectRatio: number; // width / height
}
interface PositionedCard extends CardItem {
top: number;
left: number;
width: number;
height: number;
}
export function computeMasonryLayout(
items: CardItem[],
containerWidth: number,
columnCount = 3,
gap = 16
): { positions: PositionedCard[]; totalHeight: number } {
const columnWidth = (containerWidth - (columnCount - 1) * gap) / columnCount;
const colHeights = new Array(columnCount).fill(0);
const positions: PositionedCard[] = items.map((item) => {
// 1. Find the column with the minimum current height
let shortestCol = 0;
for (let i = 1; i < columnCount; i++) {
if (colHeights[i] < colHeights[shortestCol]) {
shortestCol = i;
}
}
const cardHeight = columnWidth / item.aspectRatio;
const top = colHeights[shortestCol];
const left = shortestCol * (columnWidth + gap);
// 2. Update the shortest column's cumulative height
colHeights[shortestCol] += cardHeight + gap;
return {
...item,
top,
left,
width: columnWidth,
height: cardHeight,
};
});
return {
positions,
totalHeight: Math.max(...colHeights),
};
}import { useState, useEffect, useMemo } from "react";
export function useMasonryVirtualizer(
positions: PositionedCard[],
scrollTop: number,
viewportHeight: number,
buffer = 600
) {
return useMemo(() => {
const minVisibleY = scrollTop - buffer;
const maxVisibleY = scrollTop + viewportHeight + buffer;
// Filter cards intersecting the active window
return positions.filter(
(card) => card.top + card.height >= minVisibleY && card.top <= maxVisibleY
);
}, [positions, scrollTop, viewportHeight, buffer]);
}
export function MasonryGrid({ cards, containerWidth }: { cards: CardItem[]; containerWidth: number }) {
const [scrollTop, setScrollTop] = useState(0);
const { positions, totalHeight } = useMemo(
() => computeMasonryLayout(cards, containerWidth, 3, 16),
[cards, containerWidth]
);
const visibleCards = useMasonryVirtualizer(positions, scrollTop, window.innerHeight);
return (
<div style={{ position: "relative", height: totalHeight, width: containerWidth }}>
{visibleCards.map((card) => (
<div
key={card.id}
style={{
position: "absolute",
transform: `translate3d(${card.left}px, ${card.top}px, 0)`,
width: card.width,
height: card.height,
willChange: "transform",
}}
>
<img
src={`/api/image/${card.id}`}
alt=""
loading="lazy"
style={{ width: "100%", height: "100%", objectFit: "cover" }}
/>
</div>
))}
</div>
);
}Interview-Ready Answers
Maintain an array of column heights, place each incoming card into the shortest column using absolute positioning or CSS column tracks, and virtualize out-of-viewport cards to sustain 60 FPS across infinite scrolls.
Follow-up Questions & Deep Dives
🎯 Code Output — What Does This Print?
const colHeights = [200, 150, 300]; // 3 columns
const newCardHeight = 100;
// Which column index receives the new card in a greedy masonry algorithm?
let shortestCol = 0;
for (let i = 1; i < colHeights.length; i++) {
if (colHeights[i] < colHeights[shortestCol]) shortestCol = i;
}
colHeights[shortestCol] += newCardHeight;
console.log("Placed in col:", shortestCol);
console.log("New heights:", colHeights);Common Mistakes & Anti-Patterns
- Waiting for `<img>` `onLoad` event to compute card layout, causing severe Cumulative Layout Shift (CLS)
- Using CSS `top` and `left` properties instead of `transform: translate3d()` triggering layout reflow on every scroll tick
- Not virtualizing cards, leading to 10,000+ DOM nodes and browser out-of-memory crashes
- Using array index as key in Masonry lists, causing layout corruption on dynamic insertion
Implementation Evolution: Anti-Pattern to Production
❌ Anti-Pattern: Unbounded DOM Node Growth in Infinite Masonry
// ❌ Anti-Pattern: Appending thousands of unvirtualized DOM nodes on infinite scroll
function InfiniteGrid({ items }: { items: Item[] }) {
return (
<div className="grid">
{items.map((item) => (
<div key={item.id}>
<img src={item.url} />
</div>
))}
</div>
);
}✅ Correct Pattern: Virtual Windowing with Static Aspect-Ratio Reservation
// ✅ Correct: Only render visible cards, reserve aspect-ratio to prevent CLS
function VirtualMasonry({ visibleItems, containerHeight }: { visibleItems: PositionedCard[]; containerHeight: number }) {
return (
<div style={{ position: "relative", height: containerHeight, width: "100%" }}>
{visibleItems.map((item) => (
<div
key={item.id}
style={{
position: "absolute",
transform: `translate3d(${item.left}px, ${item.top}px, 0)`,
width: item.width,
height: item.height,
}}
>
<CardComponent item={item} />
</div>
))}
</div>
);
}🚀 Production-Grade (Hardened): Production-Grade Masonry with BlurHash, Memory Eviction & ResizeObserver
// 🚀 Production: Dynamic ResizeObserver, IntersectionObserver image decoding, LRU eviction
import { useState, useRef, useEffect, useMemo, useCallback } from "react";
export function ProductionMasonry<T extends { id: string; width: number; height: number }>({
items,
renderItem,
gap = 16,
}: {
items: T[];
renderItem: (item: T, width: number) => React.ReactNode;
gap?: number;
}) {
const containerRef = useRef<HTMLDivElement>(null);
const [containerWidth, setContainerWidth] = useState(1200);
const [scrollTop, setScrollTop] = useState(0);
// Responsive column calculation
const columnCount = Math.max(1, Math.floor(containerWidth / 280));
useEffect(() => {
if (!containerRef.current) return;
const observer = new ResizeObserver((entries) => {
if (entries[0]?.contentRect.width) {
setContainerWidth(entries[0].contentRect.width);
}
});
observer.observe(containerRef.current);
return () => observer.disconnect();
}, []);
// Compute absolute coordinates
const { positions, totalHeight } = useMemo(() => {
const colWidth = (containerWidth - (columnCount - 1) * gap) / columnCount;
const colHeights = new Array(columnCount).fill(0);
const pos = items.map((item) => {
let shortest = 0;
for (let i = 1; i < columnCount; i++) {
if (colHeights[i] < colHeights[shortest]) shortest = i;
}
const cardHeight = colWidth * (item.height / item.width);
const top = colHeights[shortest];
const left = shortest * (colWidth + gap);
colHeights[shortest] += cardHeight + gap;
return { item, top, left, width: colWidth, height: cardHeight };
});
return { positions: pos, totalHeight: Math.max(...colHeights, 0) };
}, [items, containerWidth, columnCount, gap]);
// Windowing calculation
const visible = useMemo(() => {
const buffer = 800;
const min = scrollTop - buffer;
const max = scrollTop + window.innerHeight + buffer;
return positions.filter((p) => p.top + p.height >= min && p.top <= max);
}, [positions, scrollTop]);
return (
<div
ref={containerRef}
style={{ position: "relative", height: totalHeight, width: "100%" }}
>
{visible.map(({ item, top, left, width, height }) => (
<div
key={item.id}
style={{
position: "absolute",
top: 0,
left: 0,
transform: `translate3d(${left}px, ${top}px, 0)`,
width,
height,
willChange: "transform",
}}
>
{renderItem(item, width)}
</div>
))}
</div>
);
}⚠️ Trick Questions & Interviewer Traps
Q1:Why does standard CSS `column-count` fail for interactive infinite-scroll feeds?
Q2:How do you calculate card heights before images have finished downloading?
📋 Rapid Revision Cheat Sheet
- Greedy Shortest Column: Always insert incoming card into `min(colHeights)`.
- Zero CLS: Backend sends `width` and `height` metadata so client reserves aspect ratio upfront.
- 2D Windowing: Only render cards intersecting `[scrollTop - buffer, scrollTop + viewportHeight + buffer]`.
- GPU Translate: Place cards using `transform: translate3d(x, y, 0)` rather than top/left.
- Memory Budget: Unmount off-screen images and release canvas/bitmaps to prevent iOS OOM.
- Resize Handling: Debounce `ResizeObserver` callbacks to recompute column tracks smoothly.
Real-World Architectural Scenario
A photo sharing feed with 50,000 items crashes Safari on iOS after 2 minutes of continuous scrolling. Chrome DevTools shows 2.2GB GPU memory. How do you resolve this?
Rate Your Readiness
Rate how comfortably you can explain this in an interview to update your global readiness gauge: