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 columns | Split into columns in JS | Masonry JS library | display: grid-lanes | |
|---|---|---|---|---|
| JavaScript needed | No | Yes, to pick the column count | Yes, it positions every item | No |
| Order of items | Down each column | Across, in turn | Across, into the shortest column | Across, into the shortest column |
| Columns end at the same height | Roughly, the browser balances them | No | Roughly | Roughly |
| Browser support | Every browser | Every browser | Every browser | Safari 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 width | Columns |
|---|---|
| Under 640px | 1 |
| 640px to 1023px | 2 |
| 1024px to 1279px | 3 |
| 1280px and up | 4 |
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.
Originally published on hassandev.me.

