Skip to content
Back to snippets
nextjsreacttypescriptlayoutfrontendmasonry

Building a Responsive Masonry Layout in Next.js

5 min read605 views

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:

  • Pinterest
  • 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:

  1. Detect screen size
  2. Determine column count
  3. 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.

useColumnCount.ts
"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.

lib/data.ts
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.

createMasonryLayout.ts
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.

app/masonry/page.tsx
"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.