Skip to content
GeeksSmith
advanced 9 min read

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

Featured in Staff/Lead and UI Developer interviews (Tekion Corp, Pinterest, Meta, Amazon). It evaluates math/layout computation, 60fps scroll virtualization with uneven heights, image memory budgeting, and resilient API pagination.

Visual & Interactive Explanation

Masonry Layout Approaches Comparison

Interactive Comparison

CSS Columns / Grid

Native CSS column-count

Limited
LayoutOrdered top-to-bottom per column (breaks chronologic order)
VirtualizationNearly impossible with infinite scroll
Reflow CostFull column rebalance on dynamic insert

JS Greedy Absolute Positioning

Industry Standard (Pinterest/Flickr)

Production Standard
LayoutChronological: card always goes to shortest column
VirtualizationO(1) binary search lookup for visible bounds
Reflow CostZero reflow (GPU translate3d positioning)

Code Examples & Implementation

Greedy Shortest-Column Masonry Math Calculator
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),
  };
}
Virtualized Masonry Hook with Viewport Windowing
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?

Question 1medium
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>
  );
}
Why this breaks: As the user scrolls through 5,000+ pins, keeping all DOM nodes and decoded bitmaps in memory leads to high memory footprint (>1.5GB) and causes browser tab crashes (especially on mobile WebKit) along with janky 15 FPS scrolling.

✅ 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:

Rate your confidence: