Building a Responsive Masonry Layout in Next.js
Learn how to create a custom responsive masonry image grid in Next.js using React hooks and dynamic column balancing.
Modern image galleries often use masonry layouts to efficiently display content with varying image sizes.
Unlike traditional CSS grids where rows stay aligned, masonry layouts dynamically arrange items based on available vertical space.
This creates a more natural visual flow commonly seen in:
- Unsplash
- Dribbble
- Portfolio websites
In this guide, we'll build a custom responsive masonry layout in Next.js without relying on third-party libraries.
Why Build a Custom Masonry Layout?
Although there are packages available, building your own solution gives more control over:
- Responsive behavior
- Performance optimization
- Layout logic
- Image loading
- Caching
This also removes unnecessary dependencies.
Understanding the Layout Strategy
The layout works in three stages:
- Detect screen size
- Determine column count
- Place each image into the shortest column
Rather than inserting images sequentially, each image gets assigned to whichever column currently has the smallest height.
This creates balanced columns automatically.
Creating a Responsive Column Hook
First, create a hook that changes the number of columns based on screen width.
"use client";
import { useEffect, useState } from "react";
export function useColumnCount() {
const [columnCount, setColumnCount] = useState(3);
useEffect(() => {
const updateColumns = () => {
const width = window.innerWidth;
if (width < 640) {
setColumnCount(1); // mobile
} else if (width < 1024) {
setColumnCount(2); // tablet / below desktop
} else {
setColumnCount(3); // desktop
}
};
updateColumns();
window.addEventListener("resize", updateColumns);
return () => {
window.removeEventListener("resize", updateColumns);
};
}, []);
return columnCount;
}This hook updates automatically whenever the viewport changes.
Creating Image Data
Before building the masonry algorithm, we need a collection of images for our gallery.
export const images = [
{ id: "1", src: "https://picsum.photos/id/10/600/400" },
{ id: "2", src: "https://picsum.photos/id/11/600/800" },
{ id: "4", src: "https://picsum.photos/id/13/600/700" },
{ id: "3", src: "https://picsum.photos/id/12/600/500" },
{ id: "5", src: "https://picsum.photos/id/14/600/450" },
{ id: "6", src: "https://picsum.photos/id/15/600/900" },
{ id: "7", src: "https://picsum.photos/id/16/600/550" },
{ id: "8", src: "https://picsum.photos/id/17/600/750" },
{ id: "9", src: "https://picsum.photos/id/18/600/650" },
{ id: "10", src: "https://picsum.photos/id/19/600/650" },
{ id: "11", src: "https://picsum.photos/id/20/600/650" },
{ id: "12", src: "https://picsum.photos/id/21/600/650" },
{ id: "13", src: "https://picsum.photos/id/22/600/650" },
{ id: "14", src: "https://picsum.photos/id/23/600/650" },
{ id: "15", src: "https://picsum.photos/id/24/600/650" },
{ id: "16", src: "https://picsum.photos/id/25/600/650" },
{ id: "17", src: "https://picsum.photos/id/26/600/650" },
{ id: "18", src: "https://picsum.photos/id/27/600/650" },
{ id: "19", src: "https://picsum.photos/id/28/600/650" },
{ id: "20", src: "https://picsum.photos/id/29/600/650" },
];These images intentionally use different aspect ratios and dimensions.
Now that we have our image data ready, we can build the layout algorithm.
Building the Masonry Algorithm
Now we need a function that distributes images into balanced columns.
type Image = {
id: string;
src: string;
};
type ImageDimensions = {
width: number;
height: number;
};
export async function createMasonryLayout(
images: Image[],
columnCount: number,
imageDimensions: React.RefObject<Record<string, ImageDimensions>>
) {
const imagesWithDimensions = await Promise.all(
images.map(
(image) =>
new Promise<Image & ImageDimensions>((resolve) => {
const cached = imageDimensions.current?.[image.src];
if (cached) {
resolve({ ...image, ...cached });
return;
}
const img = new window.Image();
img.src = image.src;
img.onload = () => {
const dimensions = {
width: img.naturalWidth,
height: img.naturalHeight,
};
imageDimensions.current![image.src] = dimensions;
resolve({ ...image, ...dimensions });
};
img.onerror = () => {
resolve({ ...image, width: 600, height: 300 });
};
})
)
);
const columns = Array.from({ length: columnCount }, () => ({
items: [] as (Image & ImageDimensions)[],
totalHeight: 0,
}));
imagesWithDimensions.forEach((image) => {
const shortestColumn = columns.reduce((prev, current) => (current.totalHeight < prev.totalHeight ? current : prev));
shortestColumn.items.push(image);
columns[columns.indexOf(shortestColumn)].totalHeight += image.height;
});
return columns.map((col) => col.items);
}
The algorithm continuously finds the shortest column and inserts the next image there.
Creating the Masonry Component
Now we can combine everything into a reusable component.
"use client";
import { createMasonryLayout } from "@/lib/createMasonryLayout";
import { images } from "@/lib/data";
import { useColumnCount } from "@/lib/useColumnCount";
import Image from "next/image";
import { useEffect, useRef, useState } from "react";
type ImageProps = {
id: string;
height: number;
width: number;
src: string;
};
type RefProps = Record<string, { width: number; height: number }>;
const Masonry = () => {
const columnCount = useColumnCount();
const [columns, setColumns] = useState<ImageProps[][]>([]);
const imageDimensions = useRef<RefProps>({});
useEffect(() => {
async function buildLayout() {
const result = await createMasonryLayout(images as ImageProps[], columnCount, imageDimensions);
setColumns(result);
}
buildLayout();
}, [columnCount]);
return (
<div className="flex gap-4">
{columns.map((column, i) => (
<div key={i} className="flex flex-1 flex-col gap-4">
{column.map((image) => (
<Image key={image.id} alt="" src={image.src} width={image.width} height={image.height} className="w-full rounded-xl object-cover" />
))}
</div>
))}
</div>
);
};
export default Masonry;This component rebuilds the layout whenever the number of columns changes.
Why Cache Image Dimensions?
Without caching, every resize event would repeatedly calculate image dimensions.
const cached = imageDimensions.current[image.src];Caching improves:
- Performance
- Resize responsiveness
- Reduced image processing
- Better user experience
Benefits of This Approach
This custom implementation provides:
- Responsive layouts
- Dynamic balancing
- Cached dimensions
- Next.js image optimization
- No external dependencies
Final Thoughts
Masonry layouts create a visually appealing experience for image-heavy applications.
By combining:
- React hooks
- Dynamic layout calculations
- Image dimension caching
- Responsive breakpoints
you can build a performant Pinterest-style layout entirely with Next.js and TypeScript.
This approach stays lightweight while remaining flexible enough for production applications.