Build a Production-Ready React Masonry Grid Component

Build a robust React masonry grid: approaches, pitfalls, and a production-ready component with ResizeObserver, responsive columns, and smooth reflows.

ASOasis
8 min read
Build a Production-Ready React Masonry Grid Component

Image used for representation purposes only.

Overview

Masonry layouts arrange items of varying heights into columns with minimal vertical gaps, creating a “Pinterest-like” grid. Unlike a strict row-first grid, masonry uses a column-first flow: each new item is placed under the shortest column available. This pattern shines for photo galleries, card feeds, dashboards, and any UI where content height is unpredictable.

In React, you can achieve masonry in three main ways:

  • CSS-only using Multi-Column Layout (fast to ship, limited control)
  • Experimental CSS Grid “masonry” features (future-facing, not yet universally reliable)
  • JavaScript-driven positioning (most control, best consistency, costs a bit of code)

This article explains each approach and then walks through building a production-ready React Masonry component with responsive columns, smooth reflow, and ResizeObserver support.

Approach 1: CSS Multi-Column (the quick win)

If you need something simple, CSS Multi-Column Layout can produce a masonry-like effect with just a few rules:

.masonry {
  column-count: 4;            /* or use media queries to vary by width */
  column-gap: 16px;
}
.masonry > * {
  break-inside: avoid;        /* prevent elements from splitting across columns */
  margin-bottom: 16px;
}

Pros:

  • Minimal JavaScript
  • Simple CSS, great for read-only galleries

Cons:

  • Visual order flows top-to-bottom then left-to-right, which can be surprising
  • Harder to animate reflows
  • Limited control over alignment and per-item transitions

Approach 2: CSS Grid Masonry (experimental)

There is ongoing work in CSS to support masonry-like behavior directly in Grid. While promising, feature support remains experimental and inconsistent across browsers. If you adopt it, ship a graceful fallback (e.g., Multi-Column or JS-driven) and guard behind feature queries. For most production apps today, treat this as an enhancement rather than a baseline.

Approach 3: JavaScript Positioning (full control)

The most robust technique for React is to compute positions in JavaScript and absolutely position items within a relatively positioned container. The algorithm is straightforward:

  1. Determine the number of columns based on container width and a target column width.
  2. Measure each item’s height.
  3. Place each item into the shortest column (greedy “bin packing” per column).
  4. Set each item’s transform to its computed x/y and set the container’s height to the tallest column.

This approach enables:

  • Consistent column-first order regardless of CSS quirks
  • Smooth animated reflows on resize or content change
  • Fine-grained control over gaps, alignment, and responsive behavior

A Production-Ready React Masonry Component

Below is a compact, framework-agnostic component you can drop into most projects. It:

  • Uses ResizeObserver to respond to container and item size changes
  • Calculates columns from a minimum column width and gap
  • Preserves DOM order for accessibility while changing only visual position
  • Supports responsive breakpoints via a function prop

Component API

  • items: T[] — your data
  • renderItem(item, i): ReactNode — how to render an item
  • minColumnWidth: number — target minimum width per column (in px)
  • gap?: number — horizontal/vertical gap (default 16)
  • getKey?: (item: T, i: number) => React.Key — key extractor
  • className?: string — container class

Implementation (TypeScript/React)

import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';

type MasonryProps<T> = {
  items: T[];
  renderItem: (item: T, index: number) => React.ReactNode;
  minColumnWidth: number; // e.g., 280
  gap?: number;           // e.g., 16
  getKey?: (item: T, index: number) => React.Key;
  className?: string;
};

type Layout = {
  cols: number;
  colWidth: number;
  positions: { x: number; y: number }[];
  height: number;
};

function computeLayout(heights: number[], width: number, minColWidth: number, gap: number): Layout {
  const cols = Math.max(1, Math.floor((width + gap) / (minColWidth + gap)));
  const colWidth = Math.floor((width - gap * (cols - 1)) / cols);
  const colHeights = new Array(cols).fill(0) as number[];
  const positions: { x: number; y: number }[] = [];

  for (let i = 0; i < heights.length; i++) {
    // find the shortest column (first match for stability)
    let col = 0;
    for (let c = 1; c < cols; c++) if (colHeights[c] < colHeights[col]) col = c;

    const x = col * (colWidth + gap);
    const y = colHeights[col];
    positions.push({ x, y });
    colHeights[col] += heights[i] + gap;
  }

  const height = Math.max(0, Math.max(...colHeights) - gap);
  return { cols, colWidth, positions, height };
}

export function Masonry<T>({
  items,
  renderItem,
  minColumnWidth,
  gap = 16,
  getKey,
  className
}: MasonryProps<T>) {
  const containerRef = useRef<HTMLDivElement | null>(null);
  const itemRefs = useRef<HTMLDivElement[]>([]);
  const [containerWidth, setContainerWidth] = useState(0);
  const [heights, setHeights] = useState<number[]>([]);
  const [layout, setLayout] = useState<Layout | null>(null);

  // Track container width via ResizeObserver
  useLayoutEffect(() => {
    const el = containerRef.current;
    if (!el) return;
    const ro = new ResizeObserver(() => {
      const w = Math.floor(el.clientWidth);
      if (w !== containerWidth) setContainerWidth(w);
    });
    ro.observe(el);
    return () => ro.disconnect();
  }, [containerWidth]);

  // Measure each item height via ResizeObserver
  useLayoutEffect(() => {
    const ros: ResizeObserver[] = [];
    const nextHeights = new Array(items.length).fill(0);

    itemRefs.current = itemRefs.current.slice(0, items.length);

    items.forEach((_, i) => {
      const node = itemRefs.current[i];
      if (!node) return;
      const ro = new ResizeObserver(() => {
        const h = Math.ceil(node.offsetHeight);
        if (nextHeights[i] !== h) {
          nextHeights[i] = h;
          setHeights(prev => {
            const arr = prev.slice();
            arr[i] = h;
            return arr;
          });
        }
      });
      ro.observe(node);
      ros.push(ro);
    });

    // Initialize heights synchronously when possible
    items.forEach((_, i) => {
      const node = itemRefs.current[i];
      if (node) nextHeights[i] = Math.ceil(node.offsetHeight);
    });
    setHeights(nextHeights);

    return () => ros.forEach(ro => ro.disconnect());
  }, [items.length]);

  // Compute layout when sizes change
  useEffect(() => {
    if (!containerWidth || heights.length !== items.length) return;
    if (heights.some(h => !Number.isFinite(h))) return;
    setLayout(computeLayout(heights, containerWidth, minColumnWidth, gap));
  }, [containerWidth, heights.join(','), items.length, minColumnWidth, gap]);

  const containerStyle: React.CSSProperties = useMemo(() => ({
    position: 'relative',
    height: layout?.height ?? 'auto'
  }), [layout]);

  return (
    <div ref={containerRef} className={className} style={containerStyle}>
      {items.map((item, i) => {
        const pos = layout?.positions[i];
        const width = layout?.colWidth ?? 'auto';
        const style: React.CSSProperties = pos
          ? {
              position: 'absolute',
              width,
              transform: `translate(${pos.x}px, ${pos.y}px)`,
              transition: 'transform 200ms ease',
              willChange: 'transform'
            }
          : { visibility: 'hidden' }; // hide until first layout

        return (
          <div
            key={getKey ? getKey(item, i) : i}
            ref={(el) => { if (el) itemRefs.current[i] = el; }}
            style={style}
            role="listitem"
          >
            {renderItem(item, i)}
          </div>
        );
      })}
    </div>
  );
}

Usage Example

import React from 'react';
import { Masonry } from './Masonry';

const photos = new Array(24).fill(0).map((_, i) => ({
  id: i,
  src: `https://picsum.photos/id/${i + 10}/400/${300 + ((i * 37) % 150)}`,
  alt: `Random ${i}`
}));

export default function GalleryPage() {
  return (
    <Masonry
      items={photos}
      minColumnWidth={260}
      gap={16}
      getKey={(p) => p.id}
      renderItem={(p) => (
        <figure style={{ margin: 0, borderRadius: 8, overflow: 'hidden', background: '#f3f4f6' }}>
          <img
            src={p.src}
            alt={p.alt}
            loading="lazy"
            decoding="async"
            style={{ display: 'block', width: '100%', height: 'auto' }}
          />
        </figure>
      )}
    />
  );
}

Responsive Strategy

  • Choose a sensible minColumnWidth (e.g., 240–320 px). The component will automatically compute the number of columns for the current container width.
  • Adjust gap with media queries or provide different minColumnWidth values per breakpoint if you wrap the component.
  • For center alignment, wrap the container in a parent with padding and max-width to visually center the grid.

Handling Dynamic Content

  • Heights can change after initial render due to fonts loading, async content, or user actions. Using ResizeObserver on each item ensures the layout reflows automatically.
  • For frequent updates (e.g., live feeds), debounce recomputation with requestAnimationFrame or a microtask queue.
  • If items may collapse/expand, ensure transitions run on transform/opacity, not height, for smoother performance.

Performance Checklist

  • Avoid re-rendering the full list: keep items stable via getKey.
  • Use will-change: transform on positioned items.
  • Consider virtualization if your grid exceeds a few hundred items; render only what’s near the viewport.
  • Lazy-load media (loading=“lazy”) and ship responsive images (srcset/sizes) to reduce layout shifts and bandwidth.
  • Batch layout work inside useLayoutEffect/useEffect and avoid synchronously forcing style/layout multiple times per frame.

Accessibility Notes

  • The DOM order should match reading order; the JS layout only changes visual position. Screen readers and keyboard navigation will still follow DOM order.
  • Use role=“list” on the container and role=“listitem” on items if your content is list-like.
  • Provide meaningful alt text for images and visible focus outlines for interactive content inside items.

Testing and Debugging

  • Extract computeLayout into a pure function and write unit tests for known input/output pairs (e.g., 3 columns, specific heights, and gaps).
  • Test edge cases: zero items, one column, huge gaps, rapidly changing container width.
  • In development, visualize columns by adding background stripes and drawing rects for computed positions.

When to Use a Library

If you need advanced features like virtualized masonry, drag-and-drop, or infinite grids, consider mature libraries. Options include:

  • A lightweight CSS-based React masonry wrapper for quick starts
  • A headless JS-driven masonry for fine control
  • Grid/infinite-scroll libraries that support masonry-like packing and virtualization

Evaluate bundle size, SSR compatibility, and whether the library preserves DOM order. Your bespoke component above often covers 80% of use cases with minimal overhead.

Common Pitfalls and Fixes

  • “My grid jumps on first render.” Hide items until the first layout, then fade in. Or pre-measure known aspect ratios to set placeholder heights.
  • “Content overlaps on resize.” Ensure container height updates from the latest layout and consider throttling resize callbacks.
  • “Animations feel janky.” Keep animations to transforms and opacity; avoid animating width/height/top/left.
  • “Keyboard focus is hard to track.” Preserve DOM order and ensure focus styles aren’t obscured by overlapping neighbors.

Conclusion

A masonry layout is a perfect fit for heterogeneous content in React. Start with CSS Multi-Column for fast results, but reach for a JavaScript-driven component when you need reliability, animation, and full control. The component shown here is small, accessible, and production-friendly—easy to adapt with virtualization, drag-and-drop, or custom transitions as your UI grows.

Related Posts