Masonry Layout System Design
Problem Statement
Design a Pinterest-like masonry layout where cards with different heights are arranged into columns with minimal vertical gaps.
Common use cases include:
- Pinterest-style feeds
- Photo galleries
- Product discovery grids
- Marketplace listings
- News or media card layouts
The solution should support:
- Variable image and card heights
- Pagination and infinite scroll
- Responsive column count
- Smooth loading
- Correct image sizing
- Good render performance
- Stable layout with minimal layout shift
What Is Masonry Layout?
A normal grid lays out items row by row:
[ A ][ B ][ C ]
[ D ][ E ][ F ]
When card heights vary, a normal grid can create gaps:
[ Tall A ][ Short B ][ Medium C ]
[ ][ D ][ E ]
A masonry layout places the next item into the shortest column:
Column 1 Column 2 Column 3
A B C
A D C
E D F
The goal is not perfect sorting. The goal is to create visually balanced columns while preserving a reasonable feed order.
High-Level Approaches
Option 1: CSS Columns
.masonry {
column-count: 3;
column-gap: 16px;
}
.item {
break-inside: avoid;
margin-bottom: 16px;
}
Pros
- Very simple
- Browser handles layout
- Good for static content
Cons
- Items flow vertically first, not left-to-right
- Harder to preserve feed order
- Harder to virtualize
- Harder to support pagination predictably
- Not ideal for complex interactive cards
This approach is acceptable for simple static galleries.
Option 2: CSS Grid Masonry
There is emerging CSS support for masonry-like grid layouts, but it is not yet something I would rely on for broad production usage.
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-rows: masonry;
}
Pros
- Clean native API eventually
- Browser layout engine can handle placement
Cons
- Browser support is not consistently available across major browsers
- Risky for production if cross-browser compatibility is required
Option 3: JavaScript-Calculated Masonry
This is the most controllable approach.
The layout engine calculates:
- Number of columns
- Column width
- Rendered item height
- Which column each item should go into
- Absolute
xandyposition for every item - Container height
This gives us control over:
- Pagination
- Infinite scroll
- Virtualization
- Animations
- Responsive behavior
- Stable layout before images load
For a product-grade feed, this is usually the strongest answer.
Core Layout Algorithm
Maintain an array of column heights.
For each item:
- Find the column with the smallest height.
- Place the item in that column.
- Set its
xposition based on column index. - Set its
yposition based on current column height. - Increase that column height by item height plus gap.
Example:
let columnHeights = [0, 0, 0];
// Item A height 200 -> column 0
columnHeights = [216, 0, 0];
// Item B height 100 -> column 1
columnHeights = [216, 116, 0];
// Item C height 300 -> column 2
columnHeights = [216, 116, 316];
// Item D height 150 -> column 1
columnHeights = [216, 282, 316];
JavaScript Layout Function
/**
* Calculate masonry positions.
*
* @param {Object} params
* @param {Array} params.items - Items with width and height metadata.
* @param {number} params.containerWidth - Width of the masonry container.
* @param {number} params.columnWidth - Desired minimum column width.
* @param {number} params.gap - Gap between items.
* @returns {{
* positionedItems: Array,
* containerHeight: number,
* columnCount: number
* }}
*/
function calculateMasonryLayout({
items,
containerWidth,
columnWidth = 240,
gap = 16,
}) {
const columnCount = Math.max(
1,
Math.floor((containerWidth + gap) / (columnWidth + gap)),
);
const actualColumnWidth =
(containerWidth - gap * (columnCount - 1)) / columnCount;
const columnHeights = new Array(columnCount).fill(0);
const positionedItems = items.map((item) => {
const shortestColumnIndex = findShortestColumn(columnHeights);
const aspectRatio = item.height / item.width;
const renderedHeight = actualColumnWidth * aspectRatio;
const x = shortestColumnIndex * (actualColumnWidth + gap);
const y = columnHeights[shortestColumnIndex];
columnHeights[shortestColumnIndex] += renderedHeight + gap;
return {
...item,
x,
y,
width: actualColumnWidth,
height: renderedHeight,
columnIndex: shortestColumnIndex,
};
});
const containerHeight = Math.max(...columnHeights) - gap;
return {
positionedItems,
containerHeight,
columnCount,
};
}
function findShortestColumn(columnHeights) {
let shortestIndex = 0;
for (let i = 1; i < columnHeights.length; i++) {
if (columnHeights[i] < columnHeights[shortestIndex]) {
shortestIndex = i;
}
}
return shortestIndex;
}
Rendering the Layout
The container uses position: relative.
Each item uses position: absolute.
<div class="masonry" style="height: 1200px">
<div class="masonry-item" style="transform: translate(0px, 0px)">
...
</div>
<div class="masonry-item" style="transform: translate(256px, 0px)">
...
</div>
</div>
Use transform: translate(...) instead of top and left where possible because transforms are generally more animation-friendly and avoid some layout recalculation costs.
.masonry {
position: relative;
width: 100%;
}
.masonryItem {
position: absolute;
will-change: transform;
}
Example item renderer:
function MasonryItem({ item }) {
return (
<div
className="masonryItem"
style={{
width: item.width,
height: item.height,
transform: `translate(${item.x}px, ${item.y}px)`,
}}
>
<img
src={item.src}
alt={item.alt ?? ""}
width={item.width}
height={item.height}
/>
</div>
);
}
React TypeScript Implementation
import React, {
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
export type MasonryItemData = {
id: string;
src: string;
alt?: string;
width: number;
height: number;
};
type PositionedMasonryItem = MasonryItemData & {
x: number;
y: number;
renderedWidth: number;
renderedHeight: number;
columnIndex: number;
};
type MasonryLayoutResult = {
items: PositionedMasonryItem[];
containerHeight: number;
columnCount: number;
};
type MasonryProps = {
items: MasonryItemData[];
columnWidth?: number;
gap?: number;
};
export function Masonry({
items,
columnWidth = 240,
gap = 16,
}: MasonryProps) {
const containerRef = useRef<HTMLDivElement | null>(null);
const [containerWidth, setContainerWidth] = useState(0);
useLayoutEffect(() => {
if (!containerRef.current) return;
const element = containerRef.current;
const resizeObserver = new ResizeObserver((entries) => {
const entry = entries[0];
setContainerWidth(entry.contentRect.width);
});
resizeObserver.observe(element);
return () => {
resizeObserver.disconnect();
};
}, []);
const layout = useMemo(() => {
if (containerWidth === 0) {
return {
items: [],
containerHeight: 0,
columnCount: 1,
};
}
return calculateMasonryLayout({
items,
containerWidth,
columnWidth,
gap,
});
}, [items, containerWidth, columnWidth, gap]);
return (
<div
ref={containerRef}
className="masonry"
style={{
position: "relative",
width: "100%",
height: layout.containerHeight,
}}
>
{layout.items.map((item) => (
<div
key={item.id}
className="masonryItem"
style={{
position: "absolute",
width: item.renderedWidth,
height: item.renderedHeight,
transform: `translate(${item.x}px, ${item.y}px)`,
}}
>
<img
src={item.src}
alt={item.alt ?? ""}
width={item.renderedWidth}
height={item.renderedHeight}
loading="lazy"
decoding="async"
style={{
width: "100%",
height: "100%",
objectFit: "cover",
display: "block",
}}
/>
</div>
))}
</div>
);
}
function calculateMasonryLayout({
items,
containerWidth,
columnWidth,
gap,
}: {
items: MasonryItemData[];
containerWidth: number;
columnWidth: number;
gap: number;
}): MasonryLayoutResult {
const columnCount = Math.max(
1,
Math.floor((containerWidth + gap) / (columnWidth + gap)),
);
const actualColumnWidth =
(containerWidth - gap * (columnCount - 1)) / columnCount;
const columnHeights = new Array(columnCount).fill(0);
const positionedItems = items.map((item) => {
const shortestColumnIndex = findShortestColumn(columnHeights);
const aspectRatio = item.height / item.width;
const renderedHeight = actualColumnWidth * aspectRatio;
const x = shortestColumnIndex * (actualColumnWidth + gap);
const y = columnHeights[shortestColumnIndex];
columnHeights[shortestColumnIndex] += renderedHeight + gap;
return {
...item,
x,
y,
renderedWidth: actualColumnWidth,
renderedHeight,
columnIndex: shortestColumnIndex,
};
});
return {
items: positionedItems,
containerHeight: Math.max(...columnHeights, 0),
columnCount,
};
}
function findShortestColumn(columnHeights: number[]) {
let shortestIndex = 0;
for (let i = 1; i < columnHeights.length; i++) {
if (columnHeights[i] < columnHeights[shortestIndex]) {
shortestIndex = i;
}
}
return shortestIndex;
}
CSS
.masonry {
position: relative;
width: 100%;
}
.masonryItem {
position: absolute;
overflow: hidden;
border-radius: 12px;
background: #f3f4f6;
}
.masonryItem img {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
Handling Pagination with Masonry
When new items are fetched, avoid recalculating all previous items unless a full responsive reflow is required.
Bad approach:
Fetch page 1
Calculate layout
Fetch page 2
Recalculate page 1 and page 2 from scratch
Old items may move
Better approach:
Fetch page 1
Calculate layout
Fetch page 2
Continue from existing column heights
Append new items only
This gives a more stable feed because old items do not jump when new items arrive.
Incremental Masonry Layout for Pagination
function appendMasonryItems({
existingItems,
newItems,
columnHeights,
containerWidth,
columnCount,
gap,
}) {
const actualColumnWidth =
(containerWidth - gap * (columnCount - 1)) / columnCount;
const positionedNewItems = newItems.map((item) => {
const shortestColumnIndex = findShortestColumn(columnHeights);
const aspectRatio = item.height / item.width;
const renderedHeight = actualColumnWidth * aspectRatio;
const x = shortestColumnIndex * (actualColumnWidth + gap);
const y = columnHeights[shortestColumnIndex];
columnHeights[shortestColumnIndex] += renderedHeight + gap;
return {
...item,
x,
y,
renderedWidth: actualColumnWidth,
renderedHeight,
columnIndex: shortestColumnIndex,
};
});
return {
items: [...existingItems, ...positionedNewItems],
columnHeights,
containerHeight: Math.max(...columnHeights),
};
}
Infinite Scroll with IntersectionObserver
Use a sentinel element near the bottom of the page.
function useInfiniteScroll({
hasMore,
isLoading,
onLoadMore,
}: {
hasMore: boolean;
isLoading: boolean;
onLoadMore: () => void;
}) {
const sentinelRef = React.useRef<HTMLDivElement | null>(null);
React.useEffect(() => {
const sentinel = sentinelRef.current;
if (!sentinel) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting && hasMore && !isLoading) {
onLoadMore();
}
},
{
root: null,
rootMargin: "600px",
threshold: 0,
},
);
observer.observe(sentinel);
return () => {
observer.disconnect();
};
}, [hasMore, isLoading, onLoadMore]);
return sentinelRef;
}
Usage:
function MasonryFeed() {
const [items, setItems] = React.useState<MasonryItemData[]>([]);
const [page, setPage] = React.useState(1);
const [isLoading, setIsLoading] = React.useState(false);
const [hasMore, setHasMore] = React.useState(true);
const loadMore = React.useCallback(async () => {
if (isLoading || !hasMore) return;
setIsLoading(true);
try {
const response = await fetch(`/api/photos?page=${page}`);
const data = await response.json();
setItems((prev) => dedupeById([...prev, ...data.items]));
setPage((prev) => prev + 1);
setHasMore(data.hasMore);
} finally {
setIsLoading(false);
}
}, [page, isLoading, hasMore]);
const sentinelRef = useInfiniteScroll({
hasMore,
isLoading,
onLoadMore: loadMore,
});
return (
<>
<Masonry items={items} />
<div ref={sentinelRef} style={{ height: 1 }} />
{isLoading && <p>Loading...</p>}
</>
);
}
function dedupeById(items: MasonryItemData[]) {
const seen = new Set<string>();
return items.filter((item) => {
if (seen.has(item.id)) return false;
seen.add(item.id);
return true;
});
}
Staff-Level Pagination Considerations
For production, prefer cursor-based pagination:
GET /api/feed?cursor=abc123&limit=30
Instead of offset pagination:
GET /api/feed?page=5
Cursor pagination is more stable when new items are inserted.
Example API response:
{
"items": [
{
"id": "photo_1",
"src": "https://cdn.example.com/photo_1.jpg",
"width": 1200,
"height": 1600
}
],
"nextCursor": "def456",
"hasMore": true
}
The API should return original image dimensions. Without width and height, the client cannot reserve the correct space before images load.
Making Sure Image Size Renders Correctly
The most important rule:
Know the image aspect ratio before rendering.
Each item should include width and height metadata:
type ImageItem = {
id: string;
src: string;
width: number;
height: number;
};
Then calculate the rendered height:
const aspectRatio = item.height / item.width;
const renderedHeight = columnWidth * aspectRatio;
This prevents layout shift.
Correct Image Rendering
<img
src={item.src}
alt={item.alt ?? ""}
width={item.renderedWidth}
height={item.renderedHeight}
loading="lazy"
decoding="async"
style={{
width: "100%",
height: "100%",
objectFit: "cover",
display: "block",
}}
/>
Why this matters:
widthandheighthelp the browser reserve spaceobject-fit: coverprevents distortiondisplay: blockremoves inline image spacingloading="lazy"avoids loading all images immediatelydecoding="async"avoids blocking rendering
Handling Unknown Image Dimensions
Sometimes the API does not provide image width and height.
Option 1: Preload and Measure
function getImageSize(src) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
resolve({
width: img.naturalWidth,
height: img.naturalHeight,
});
};
img.onerror = reject;
img.src = src;
});
}
Pros
- Accurate
Cons
- Slower first render
- More network pressure
- More complicated pagination behavior
Option 2: Use Estimated Height First, Then Correct Later
const estimatedHeight = 300;
After image load, measure the actual image and update layout.
Pros
- Faster initial render
Cons
- Causes layout shift
- Recalculating layout can move items
- Bad UX for feeds
Option 3: Backend Generates Metadata
Best production solution:
- Image upload pipeline extracts width and height
- Store dimensions in the database
- API returns dimensions with every item
This is what I would choose for a serious production system.
Render Performance
Avoid Expensive Layout Recalculation
The layout algorithm is O(n * c), where:
nis the number of itemscis the number of columns
Since c is usually small, this is effectively O(n).
For 3 to 6 columns, this is fine.
For very large feeds, use:
- Incremental layout
- Virtualization
- Memoization
- Windowing
- Resize throttling
Use useMemo
const layout = useMemo(() => {
return calculateMasonryLayout({
items,
containerWidth,
columnWidth,
gap,
});
}, [items, containerWidth, columnWidth, gap]);
Avoid Layout Thrashing
Bad approach:
items.forEach((item) => {
const height = item.element.getBoundingClientRect().height;
item.element.style.top = nextTop + "px";
});
This mixes DOM reads and writes repeatedly.
Better approach:
- Calculate layout from known metadata.
- Render styles in one React pass.
- Avoid measuring every DOM node after render.
Use ResizeObserver
Do not listen to every window.resize event directly without throttling.
useLayoutEffect(() => {
const observer = new ResizeObserver((entries) => {
const width = entries[0].contentRect.width;
setContainerWidth(width);
});
if (containerRef.current) {
observer.observe(containerRef.current);
}
return () => observer.disconnect();
}, []);
For high-frequency resizing, debounce the width update:
function debounce(fn, delay) {
let timerId;
return (...args) => {
clearTimeout(timerId);
timerId = setTimeout(() => {
fn(...args);
}, delay);
};
}
Virtualization for Large Feeds
If the feed has thousands of items, rendering everything is too expensive.
Only render items whose y position is near the viewport.
function getVisibleItems({
items,
scrollTop,
viewportHeight,
overscan = 800,
}) {
const minY = scrollTop - overscan;
const maxY = scrollTop + viewportHeight + overscan;
return items.filter((item) => {
return item.y + item.renderedHeight >= minY && item.y <= maxY;
});
}
Then render only visible items:
const visibleItems = useMemo(() => {
return getVisibleItems({
items: layout.items,
scrollTop,
viewportHeight,
overscan: 800,
});
}, [layout.items, scrollTop, viewportHeight]);
Scroll tracking:
React.useEffect(() => {
let frameId = 0;
function onScroll() {
cancelAnimationFrame(frameId);
frameId = requestAnimationFrame(() => {
setScrollTop(window.scrollY);
});
}
window.addEventListener("scroll", onScroll, { passive: true });
return () => {
cancelAnimationFrame(frameId);
window.removeEventListener("scroll", onScroll);
};
}, []);
Production Masonry Architecture
Client
|
| GET /feed?cursor=abc&limit=30
v
Feed API
|
| returns items with image metadata
v
Masonry Layout Engine
|
| calculates x, y, width, height
v
Virtualized Renderer
|
| renders visible cards
v
Image CDN
|
| serves optimized responsive images
Responsive Behavior
Column count should change based on container width.
const columnCount = Math.max(
1,
Math.floor((containerWidth + gap) / (minColumnWidth + gap)),
);
Example behavior:
< 600px -> 1 column
600-900px -> 2 columns
900-1200px -> 3 columns
1200px+ -> 4 or 5 columns
When column count changes, we usually need to recalculate layout from scratch because item positions depend on the number of columns.
This is acceptable on resize because users expect responsive layout reflow.
Avoiding Layout Shift
Main techniques:
- Return image dimensions from the API.
- Reserve image space before image loads.
- Use placeholders or skeletons with the correct aspect ratio.
- Avoid changing item height after render.
- Use stable item keys.
- Avoid recalculating already-rendered pagination pages unless responsive column count changes.
Example skeleton:
<div
className="imageSkeleton"
style={{
width: item.renderedWidth,
height: item.renderedHeight,
}}
/>
Image CDN and Responsive Images
For production, do not serve full-size images into small cards.
Use CDN resizing:
<img
src={`${item.src}?w=${Math.round(item.renderedWidth * 2)}`}
srcSet={`
${item.src}?w=${Math.round(item.renderedWidth)} 1x,
${item.src}?w=${Math.round(item.renderedWidth * 2)} 2x
`}
alt={item.alt ?? ""}
loading="lazy"
decoding="async"
/>
This improves:
- Load time
- Memory usage
- Mobile performance
- Bandwidth cost
Data Model
type FeedItem = {
id: string;
title: string;
imageUrl: string;
imageWidth: number;
imageHeight: number;
createdAt: string;
};
Layout model:
type PositionedFeedItem = FeedItem & {
x: number;
y: number;
renderedWidth: number;
renderedHeight: number;
columnIndex: number;
};
Pagination model:
type FeedResponse = {
items: FeedItem[];
nextCursor: string | null;
hasMore: boolean;
};
Edge Cases
Empty List
if (items.length === 0) {
return {
items: [],
containerHeight: 0,
columnCount: 1,
};
}
Broken Images
Use fallback UI:
function ImageCard({ item }) {
const [failed, setFailed] = React.useState(false);
if (failed) {
return <div className="fallback">Image unavailable</div>;
}
return (
<img
src={item.src}
alt={item.alt ?? ""}
onError={() => setFailed(true)}
/>
);
}
Very Tall Images
Clamp max height if the product requirement needs it:
.cardImage {
max-height: 640px;
object-fit: cover;
}
Be careful: clamping can change the visual representation of the original image.
Items Inserted at the Top
For feeds where new content is inserted at the top, preserving scroll position is important.
Approach:
- Capture the current scroll anchor item.
- Insert new items.
- Recalculate layout.
- Restore scroll so the anchor item stays visually stable.
Staff Engineer Tradeoff Discussion
Simple Marketing Gallery
Use CSS columns.
.gallery {
column-count: 3;
column-gap: 16px;
}
.galleryItem {
break-inside: avoid;
}
This is simple and cheap.
Product Feed
Use JavaScript layout.
Reasons:
- Stable pagination
- Predictable item order
- Easier analytics tracking
- Better support for virtualization
- Better control over loading behavior
Large-Scale Consumer Feed
Use JavaScript layout plus:
- Cursor pagination
- Image metadata from backend
- CDN image resizing
- Virtualized rendering
- Skeleton placeholders
- Incremental append layout
- Observability around layout cost and image loading
Interview Summary Answer
I would design masonry as a client-side layout engine that places each item into the currently shortest column. The API should return image dimensions so the client can calculate rendered height before images load. The container uses relative positioning, while items are absolutely positioned with transform-based placement.
For pagination, I would use cursor-based pagination and append newly fetched items into the current column-height state instead of recalculating old pages on every fetch. This prevents existing content from jumping. For responsive resizing, I would recalculate from scratch because column count changes.
For performance, I would memoize layout computation, avoid DOM measurement loops, use ResizeObserver, lazy-load images, serve responsive CDN image sizes, and virtualize the feed once item count becomes large. The most important rendering guarantee is preserving aspect ratio and reserving image space before load to avoid layout shift.
Compact Core Logic
function masonry(items, containerWidth, minColumnWidth = 240, gap = 16) {
const columnCount = Math.max(
1,
Math.floor((containerWidth + gap) / (minColumnWidth + gap)),
);
const columnWidth =
(containerWidth - gap * (columnCount - 1)) / columnCount;
const columnHeights = Array(columnCount).fill(0);
const positioned = items.map((item) => {
const column = columnHeights.indexOf(Math.min(...columnHeights));
const height = columnWidth * (item.height / item.width);
const x = column * (columnWidth + gap);
const y = columnHeights[column];
columnHeights[column] += height + gap;
return {
...item,
x,
y,
width: columnWidth,
height,
};
});
return {
items: positioned,
height: Math.max(...columnHeights),
};
}
This is the core of masonry layout.