Jason JunJason Jun

Building a horizontal masonry feed

28 September 2025

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.

Horizontal masonry feed demoHorizontal masonry feed 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.

StructureStructure

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.

AlgorithmAlgorithm

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.

End edgeEnd edge
<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.

IndicatorIndicator

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.