I recently worked on a project that needed a horizontal masonry feed. It came with some interesting challenges, so I decided to share my solution. Rather than showing the actual work, I built a new demo that includes extra features I wanted to try.

- Demo link: https://horizontal-masonry-demo.vercel.app/
- GitHub Repo: https://github.com/htjun/horizontal-masonry-demo
For the demo, I used Next.js (mainly for the Image component), along with TypeScript and Tailwind.
Basic layout structure
The layout is a stack of rows, each with a variable number of items. Since images have different aspect ratios, their heights must be fixed to keep rows aligned. The container uses horizontal overflow scroll.
Masonry layout algorithm
The core idea is to place images in order based on their starting position. Since the layout flows horizontally, items are placed from left to right. The algorithm is simple: push each image into the row with the current shortest width.
Placement must use the displayed width, not the raw image width, since display width changes with aspect ratio. To handle this, I added a calculateDisplayWidth function that takes the image’s width, height, and the display height, then returns the displayed width.
const imageRows = Array.from({ length: rowCount }, () => [])
const rowWidths = Array(rowCount).fill(0)
for (const image of images) {
const displayWidth = calculateDisplayWidth(image.width, image.height, imageDisplayHeight)
const minWidthRowIndex = rowWidths.indexOf(Math.min(...rowWidths))
imageRows[minWidthRowIndex].push({ ...image, displayWidth })
rowWidths[minWidthRowIndex] += displayWidth
}
function calculateDisplayWidth(imageWidth, imageHeight, imageDisplayHeight) {
return Math.round((imageDisplayHeight / imageHeight) * imageWidth)
}
Dynamic row height
The project at work accepted a fixed height as a prop. For this demo, I made height dynamic so the feed fills the viewport/container.
I built a hook that computes image height from the container height and row count: it subtracts row gaps, divides by the number of rows, and applies a minimum to avoid tiny tiles. This keeps rows aligned across aspect ratios.
I used useLayoutEffect
with ResizeObserver
so the hook recalculates only when the container size changes. The update is wrapped in requestAnimationFrame to coalesce rapid resize events and avoid redundant renders.
function useDynamicImageHeight(containerRef, rowCount) {
const [imageHeight, setImageHeight] = useState(300)
useLayoutEffect(() => {
const el = containerRef.current
if (!el || rowCount <= 0) return
let rafId = null
const gap = 4 // px between rows
const recalc = () => {
if (rafId) cancelAnimationFrame(rafId)
rafId = requestAnimationFrame(() => {
const available = el.clientHeight - (rowCount - 1) * gap
const next = Math.floor(available / rowCount)
setImageHeight(next)
rafId = null
})
}
const ro = new ResizeObserver(recalc)
ro.observe(el)
recalc()
return () => {
ro.disconnect()
if (rafId) cancelAnimationFrame(rafId)
}
}, [containerRef, rowCount])
return imageHeight
}
Aligning end edge
Rows end at different widths in a masonry layout, so the right edge won’t line up by default. To align it, let the last item in each row flex-grow to fill the remaining space. No JavaScript is needed—set it from the parent using a :last-child selector.
<div className="flex w-max flex-col gap-1 [&>*:last-child]:grow">
{imageRows.map((row, i) => (
<div key={i} className="flex gap-1">
{row.map((image) => <ImageCard (omitted) />)}
</div>
))}
</div>
Scrollability indicators
A common way to show scrollability is to fade out the edges. There are multiple approaches, either with JavaScript or pure CSS. For this demo, I kept it simple by using a CSS mask combined with backdrop blur and colour. Instead of conditionally toggling the edge effect based on scroll position, I matched the side padding width to the fade width. This way, the effect only shows when the content is scrollable and disappears naturally at the edges.
Converting vertical wheel to horizontal scroll
Horizontal scroll isn’t a common interaction and isn’t always available to users. The design team requested converting vertical wheel input into horizontal scroll. I built a useWheelToHorizontalScroll
hook that listens to wheel events and applies the delta to horizontal scroll, giving a more natural interaction.
function useWheelToHorizontalScroll() {
const ref = useRef(null)
useEffect(() => {
const el = ref.current
if (!el) return
const onWheel = (e) => {
if (!e.deltaY) return
if (el.scrollWidth <= el.clientWidth) return
const scrollAmount = e.deltaY * 1.5 // adjust scroll sensitivity
el.scrollBy({ left: scrollAmount, behavior: 'instant' })
}
el.addEventListener('wheel', onWheel, { passive: true })
return () => el.removeEventListener('wheel', onWheel)
}, [])
return ref
}
Disabling vertical scroll
If you don’t call preventDefault()
on wheel and there’s vertical overflow, the page will scroll vertically while you apply horizontal scroll. Override this by attaching a non-passive wheel listener with { passive: false }
and preventing the default when you consume the delta. Use sparingly: non-passive wheel listeners can block the main thread and hurt scroll performance. Scope it to the feed container and return early when interception isn’t needed.
In my case, the page was feed-only, and the main issue was the overscroll effect some browsers show (rubber-band or glow highlighting) when scrolling beyond content. I disabled it by applying overscroll-y-none
to the root html element.