← Back to blogYouTube Video

October 24, 2026 • By

Tailwind CSS masonry layout with columns, no JavaScript

Can't play the video or having issues? Here's the direct link.

The short version

  • A masonry layout needs no JavaScript in Tailwind: put the items in a container with columns-* classes, like columns-1 sm:columns-2 lg:columns-3, and the browser stacks them into columns.
  • Give every item break-inside-avoid, or the browser can cut a card in half and continue it at the top of the next column.
  • The gap class only spaces the columns. Add a bottom margin such as mb-4 to every item for the space between cards in a column.
  • CSS columns fill the first column before starting the next, so items 1, 2 and 3 sit under each other. Use grid-lanes or JavaScript when the order should go across.
  • display: grid-lanes is native CSS masonry that puts each item in the shortest column. As of October 2026 only Safari 26.4 and later ship it, so keep CSS columns as the fallback.

To build a masonry layout with Tailwind CSS, put your items in a container with a columns-* class and give every item break-inside-avoid and a bottom margin. The browser stacks items of different heights into columns with no holes between them, like Pinterest or Unsplash, and you don't need any JavaScript for it.

Below I explain how CSS columns produce the layout, compare it with the other ways to build masonry, and build the reusable React and TypeScript component from the video above. At the end there's an upgrade to native CSS masonry with display: grid-lanes for the browsers that have it.

Why a normal grid can't do masonry

A masonry layout arranges items of different heights so each one sits right under the item above it. Photo sites use it because photos come in every shape, and cropping them all to one size throws away part of each picture.

CSS grid can't do this on its own. A grid has rows, and every row is as tall as its tallest item. Put a tall photo next to two short ones and the short ones leave an empty gap under them until the next row starts.

How CSS columns makes a masonry layout

There are several ways to build masonry, and I use the CSS columns property. It's the same feature newspapers use for text: it splits an element's content into a number of columns. The content flows down the first column, then continues at the top of the second, and so on. The browser balances the columns so they end up roughly the same height.

When the content is a list of cards, each card sits directly under the one before it, whatever its height. That's a masonry layout.

Tailwind has a utility for it. columns-3 sets columns: 3, so you get three columns, and you make it responsive with the usual breakpoint prefixes:

<div class="columns-1 gap-4 sm:columns-2 lg:columns-3 xl:columns-4">
  <!-- cards -->
</div>

gap-4 sets the space between the columns. Tailwind also has width-based classes like columns-xs, which sets a column width of 20rem (320px) instead of a count and lets the browser fit as many columns as that allows. I tested it in a container up to 1120px wide: one column on a phone, two at 700px, three at 1024px and wider.

The two classes every item needs

The container alone isn't enough. Every item needs two more things.

break-inside-avoid stops the browser from cutting an item in half. Columns are made for text, and text is allowed to continue from the bottom of one column to the top of the next. Cards are too, unless you tell the browser not to break them. In my test with plain bordered cards, two of the eight were split across two columns until I added break-inside: avoid. A card with overflow: hidden didn't split in Chromium even without it, but I wouldn't rely on that.

A bottom margin, like mb-4, puts space between the cards in a column. gap only works between columns. Without the margin, every card touches the one under it.

Ways to build a masonry layout

CSS columns isn't the only option. These are the approaches you'll find in other tutorials:

CSS columnsSplit into columns in JSMasonry JS librarydisplay: grid-lanes
JavaScript neededNoYes, to pick the column countYes, it positions every itemNo
Order of itemsDown each columnAcross, in turnAcross, into the shortest columnAcross, into the shortest column
Columns end at the same heightRoughly, the browser balances themNoRoughlyRoughly
Browser supportEvery browserEvery browserEvery browserSafari 26.4 and later

Splitting into columns means turning the list into one array per column (item 1 in column 1, item 2 in column 2 and so on) and rendering each array as its own flex column. It keeps the order across, but your code has to know how many columns there are at each screen width, and one column can end up much longer than the others.

For a gallery where the order doesn't matter much, CSS columns is the shortest code of the four, so it's what I use.

Build it with React and Tailwind

The component takes any list and a function that renders one item, so you can reuse it for photos or any other kind of card.

I tested this with Next.js 16.4, React 19.3 and Tailwind 4.3. Create the project with the default settings, which already include TypeScript and Tailwind:

npx create-next-app@latest masonry-demo --yes
cd masonry-demo

You only touch two files:

masonry-demo/
├── app/
│   └── page.tsx
└── components/
    └── Masonry.tsx

The Masonry component

Create components/Masonry.tsx:

import type { ReactNode } from "react";

type MasonryProps<T> = {
  items: T[];
  renderItem: (item: T) => ReactNode;
};

export default function Masonry<T>({ items, renderItem }: MasonryProps<T>) {
  return (
    <div className="columns-1 gap-4 sm:columns-2 lg:columns-3 xl:columns-4 *:mb-4 *:break-inside-avoid">
      {items.map((item) => renderItem(item))}
    </div>
  );
}

It takes two props. items is the list, and renderItem is a function that turns one item into JSX. The generic type T stands for whatever your items are. When you pass a list of photos, TypeScript knows renderItem gets a photo, and it complains if the function expects something else.

The *: prefix is Tailwind's variant for direct children. *:mb-4 *:break-inside-avoid puts the margin and break-inside-avoid on every item from the container, so the layout lives in this one file and renderItem only has to describe the card.

The page

Replace everything in app/page.tsx with this:

import Masonry from "@/components/Masonry";

type Photo = {
  id: number;
  name: string;
  width: number;
  height: number;
};

const photos: Photo[] = [
  { id: 10, name: "Forest above the sea", width: 600, height: 800 },
  { id: 11, name: "Misty river valley", width: 600, height: 400 },
  { id: 15, name: "Waterfall", width: 600, height: 900 },
  { id: 16, name: "Rocky shoreline", width: 600, height: 600 },
  { id: 17, name: "Path through a field", width: 600, height: 450 },
  { id: 20, name: "Desk with books", width: 600, height: 750 },
  { id: 28, name: "Mountain stream", width: 600, height: 400 },
  { id: 29, name: "Snowy peaks", width: 600, height: 900 },
  { id: 37, name: "Coastal cliffs", width: 600, height: 600 },
  { id: 39, name: "Record player", width: 600, height: 800 },
  { id: 42, name: "Coffee on a cafe table", width: 600, height: 450 },
  { id: 54, name: "Hikers on a summit", width: 600, height: 700 },
];

function renderPhoto(photo: Photo) {
  return (
    <figure key={photo.id} className="overflow-hidden rounded-lg bg-white shadow">
      <img
        src={`https://picsum.photos/id/${photo.id}/${photo.width}/${photo.height}`}
        alt={photo.name}
        width={photo.width}
        height={photo.height}
        loading="lazy"
        className="block h-auto w-full"
      />
      <figcaption className="p-3 text-sm text-gray-700">{photo.name}</figcaption>
    </figure>
  );
}

export default function Home() {
  return (
    <main className="mx-auto w-full max-w-6xl p-4">
      <Masonry items={photos} renderItem={renderPhoto} />
    </main>
  );
}

The list is twelve photos from Lorem Picsum, each cropped to a different height so the layout has something to do. Your items can be anything; only renderPhoto needs to know what's in them.

renderPhoto sets a key on the figure because React needs one on every element it renders from a list. It doesn't go in Masonry, because only your function knows which field is unique.

The width and height attributes tell the browser each photo's shape before it loads. Without them every image starts at zero height, and the cards jump around as the photos arrive. block removes the small gap under an inline image, and max-w-6xl keeps the gallery from stretching across a very wide screen.

npm run lint shows one warning here, suggesting next/image instead of <img>. That's a fair point for your own photos, which next/image can resize. These URLs already ask Picsum for the right size, so I left them as plain images.

Run it

npm run dev

Open http://localhost:3000. With Tailwind's default breakpoints, the number of columns changes with the screen width:

Screen widthColumns
Under 640px1
640px to 1023px2
1024px to 1279px3
1280px and up4

I checked each width in Chromium. No card was cut in half, and the page never got wider than the screen.

Items go down the columns first

CSS columns fills the first column before it starts the second. On a 1280px screen, items 1 to 3 (starting with the forest) fill the first column, and item 4 starts at the top of the second. People read a gallery left to right, so they see items 1, 4, 7 and 10 across the top.

For photos that doesn't matter. For a feed sorted newest first, it does. In that case, use one of the approaches that go across, or the native option below.

Native masonry with grid-lanes

CSS now has masonry built in: display: grid-lanes. You define the columns with grid-template-columns like a normal grid, and the browser puts each item in turn into whichever column is shortest. The first items go across the top row, so the order works the way people read.

As of October 2026 it's only in Safari 26.4 and later. Chrome, Edge and Firefox have it behind a flag, according to Can I use. You can add it now and keep columns as the fallback:

import type { ReactNode } from "react";

type MasonryProps<T> = {
  items: T[];
  renderItem: (item: T) => ReactNode;
};

export default function Masonry<T>({ items, renderItem }: MasonryProps<T>) {
  return (
    <div
      className="columns-1 gap-4 sm:columns-2 lg:columns-3 xl:columns-4 *:mb-4 *:break-inside-avoid
        grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4
        supports-[display:grid-lanes]:[display:grid-lanes] supports-[display:grid-lanes]:*:mb-0"
    >
      {items.map((item) => renderItem(item))}
    </div>
  );
}

The grid-cols-* classes do nothing until the container is a grid, so they can stay on all the time. supports-[display:grid-lanes]: wraps a class in @supports, so only browsers with grid-lanes switch the display and drop the bottom margins (in grid-lanes, gap-4 spaces the rows too). Every other browser keeps the columns layout.

I tested this in Chromium 153 with its experimental web platform features flag on. The first four photos sat across the top row at 1280px, and there were one to four lanes at the same breakpoints as the columns version. Without the flag the same code used columns. I haven't tested it in Safari.

What I'd use

CSS columns keeps the masonry layout short and simple. The component is a handful of lines that work in every browser, and you can drop it into any project and pass it whatever list you have. I'd only reach for a JavaScript library when the order across matters and I can't wait for grid-lanes to reach the other browsers.

Video transcript

Show
Alright, we're going to build a masonry grid layout which is what you see on Pinterest and Unsplash and other websites. This layout is basically to arrange a grid of elements with different heights. I'm going to start by creating a simple React project and we'll create a masonry component and we'll extend it so you can easily use it in your own projects as well. I'm just going to use all the default settings in here. Now this will take a minute to create. Alright, so that's done. Now I'm going to create a masonry component right here. First I'll open the project, which is right here. Okay, I'll go in here, I'll create a components folder, and I'll add a masonry component. There we go. Now there are several ways to create a masonry layout in CSS, but for our purposes I'm going to use the CSS columns property. And what this does is basically divides the elements content into a bunch of columns. Now first let actually create the actual component I going to start by giving it a couple props which are items and render item and this is just so you can actually control how you want to render these items. And since I'm using TypeScript I'm also going to assign some types to these props. I'm gonna use a generic parameter over here to represent the actual item that's being rendered and I'm going to pass this here like so and also here now let's set up the actual code for this so this is going to be very short in comparison I'm going to add these classes over here just for styling and the actual masonry layout is being done with this columns class. I'm also going to make it responsive by adding these classes and inside this component I'm just going to use the render item function that we pass in as the prop. Okay so this is all we need for the masonry component. Now let's go back to our page and actually render this. I'm going to remove all of this default HTML that we have over here And instead of this I going to render our masonry component over here. Now we do need to pass in the required props, items, and render item. So for items I'm just going to paste a list over here that I already have. But these can be anything you want basically. I'm also going to create the type for items right here and I'm going to pass this in over here. Now finally we just need a prop for render item and this is just a function that's going to describe how to render each item in this list. So let's create this now. This is going to take an item as the argument and it's going to return JSX. Now make sure to use the key because we are rendering items in a list over here. Now again you can render these items in any way you want but I just going to display the image over here and the name of the image that we have in the items Okay, now let's just pass this in over here. and finally I'm going to just limit the width of this container just so it looks a bit nicer okay now let's run this and see how far we got and there we go this This is a masonry layout and our items are being rendered here. Now the only really tiny change I'm going to make is I'm going to render the item name as well right here. This is just for styling. This is not something that's really necessary. But I'm gonna do it. Okay, let's see now. And yeah, there we go. We have every single item that we described in that list earlier, and it's being rendered here in our masonry layout.

Share this article

Hassan Aziz pointing up

Dude, you’re at the bottom of my landing page.

Just book a call already if you’re that interested.

You scrolled all the way here.