EmpireUI
Get Pro
← Blog8 min read#masonry#grid#react

Masonry Grid in React: CSS columns vs JavaScript Grid Layout

CSS columns or JS-driven masonry? We break down both approaches in React — performance, trade-offs, and when to reach for each one.

code editor showing a CSS grid layout with colorful blocks

The Problem With Masonry in 2026

Masonry layout has been on CSS Working Group wish lists since roughly 2020. And yet, as of mid-2026, native CSS masonry (grid-template-rows: masonry) is still behind a flag in Firefox and not shipping in Chrome stable. That gap between what the spec promises and what browsers actually deliver is why you're here reading this instead of just writing two CSS properties and calling it a day.

So you've got two real options: lean on column-count (pure CSS, zero JS) or build — or import — a JavaScript solution that measures item heights and places things manually. Neither is universally better. It depends on whether your items are static or dynamic, whether you care about DOM order for screen readers, and how much you want to fight the browser's layout engine.

Honestly, the CSS-columns approach covers 70% of use cases with maybe 10 lines of code. But that remaining 30% — dynamic content, lazy-loaded images, items with variable async heights — is where it falls apart hard. Let's look at both in detail.

One more thing — masonry isn't just for photo galleries. Product cards, blog post previews, dashboard widgets, even glassmorphism components laid out in a grid all benefit from a proper masonry approach when card heights vary. The technique matters more than the aesthetic.

CSS columns: The Fast Path

The column-count or column-width approach is genuinely underrated. You set a column count, items flow top-to-bottom inside each column, and the browser handles everything. No ResizeObserver, no position calculations, no re-renders. It's been production-safe since Chrome 4 and IE 10.

.masonry {
  column-count: 3;
  column-gap: 16px;
}

.masonry-item {
  break-inside: avoid;
  margin-bottom: 16px;
}

/* Responsive: 1 col on mobile, 2 on tablet, 3 on desktop */
@media (max-width: 640px) { .masonry { column-count: 1; } }
@media (min-width: 641px) and (max-width: 1024px) { .masonry { column-count: 2; } }

In React, wrapping this is trivial. You don't need any state — just render your items and let CSS do the work:

// MasonryCSS.tsx
interface MasonryCSSProps {
  items: React.ReactNode[];
  columns?: number;
  gap?: number;
}

export function MasonryCSS({ items, columns = 3, gap = 16 }: MasonryCSSProps) {
  return (
    <div
      style={{
        columnCount: columns,
        columnGap: gap,
      }}
    >
      {items.map((item, i) => (
        <div key={i} style={{ breakInside: 'avoid', marginBottom: gap }}>
          {item}
        </div>
      ))}
    </div>
  );
}

Worth noting: the biggest downside of CSS columns is DOM order vs visual order mismatch. Items flow column-by-column, not row-by-row. If you have items A, B, C, D, E, F in a 3-column layout, A/D end up in column 1, B/E in column 2, C/F in column 3. Tab order follows the DOM, so keyboard navigation jumps down each column before moving to the next. For a photo gallery that's fine. For interactive cards with focusable buttons, it's a real accessibility issue you need to address.

JavaScript Grid Layout: Full Control

The JS approach measures every item's rendered height, calculates column positions, and applies position: absolute with explicit top/left values. You get pixel-perfect placement in any order you want, but you pay for it: the container needs a known width before you can calculate anything, and images or async content that loads after the initial render can break your column heights until you trigger a recalculation.

Here's a minimal from-scratch implementation that actually works. It uses a ResizeObserver to recompute on container resize and a MutationObserver workaround for dynamic items:

import { useEffect, useRef, useState } from 'react';

interface MasonryJSProps {
  items: React.ReactNode[];
  columns?: number;
  gap?: number;
}

export function MasonryJS({ items, columns = 3, gap = 16 }: MasonryJSProps) {
  const containerRef = useRef<HTMLDivElement>(null);
  const [positions, setPositions] = useState<{ top: number; left: number }[]>([]);
  const [containerHeight, setContainerHeight] = useState(0);

  const recalculate = () => {
    const container = containerRef.current;
    if (!container) return;

    const containerWidth = container.offsetWidth;
    const colWidth = (containerWidth - gap * (columns - 1)) / columns;
    const colHeights = new Array(columns).fill(0) as number[];

    const children = Array.from(container.children) as HTMLElement[];
    const newPositions = children.map((child) => {
      const shortestCol = colHeights.indexOf(Math.min(...colHeights));
      const top = colHeights[shortestCol];
      const left = shortestCol * (colWidth + gap);

      // Force the child width so height measurement is accurate
      child.style.width = `${colWidth}px`;
      const height = child.offsetHeight;
      colHeights[shortestCol] += height + gap;

      return { top, left };
    });

    setPositions(newPositions);
    setContainerHeight(Math.max(...colHeights));
  };

  useEffect(() => {
    recalculate();
    const ro = new ResizeObserver(recalculate);
    if (containerRef.current) ro.observe(containerRef.current);
    return () => ro.disconnect();
  }, [items, columns, gap]);

  return (
    <div ref={containerRef} style={{ position: 'relative', height: containerHeight }}>
      {items.map((item, i) => (
        <div
          key={i}
          style={{
            position: 'absolute',
            top: positions[i]?.top ?? 0,
            left: positions[i]?.left ?? 0,
          }}
        >
          {item}
        </div>
      ))}
    </div>
  );
}

The critical detail is setting child.style.width *before* reading child.offsetHeight. Without constraining the width first, you get heights measured at full container width, and everything breaks when you apply the actual narrow column width. That's the bug that trips up most home-grown implementations.

In practice, the image-loading problem is the real killer. If your cards contain <img> tags, the height at measurement time might be 0 or the wrong value if the image hasn't loaded yet. The fix: add onLoad handlers on your images that call recalculate(), or slap aspect-ratio on your image containers so the browser reserves the right height before the pixel data arrives.

Using a Library: react-masonry-css and masonic

If you don't want to maintain a custom implementation, two libraries dominate: react-masonry-css (CSS columns under the hood, zero runtime overhead) and masonic (virtualized JS masonry, built for lists of thousands of items). Pick based on your content size.

# Lightweight CSS-columns wrapper
npm install react-masonry-css

# Virtualized JS masonry (large lists)
npm install masonic
// react-masonry-css — dead simple
import Masonry from 'react-masonry-css';

const breakpoints = {
  default: 4,
  1100: 3,
  700: 2,
  500: 1,
};

export function PhotoGrid({ photos }: { photos: string[] }) {
  return (
    <Masonry
      breakpointCols={breakpoints}
      className="masonry-grid"
      columnClassName="masonry-grid-column"
    >
      {photos.map((src) => (
        <img key={src} src={src} alt="" style={{ width: '100%', marginBottom: 16 }} />
      ))}
    </Masonry>
  );
}

masonic is overkill for a 20-item gallery but genuinely necessary if you're rendering 500+ items. It virtualizes the list, only mounting what's in the viewport — your scroll performance at 1000 items stays at 60fps instead of dropping to 8fps. That said, its API is more involved and it requires knowing item heights upfront (or estimating them), so you'll need to wire up a useSize hook or provide an estimateSize callback.

Quick aside: if your layout is more about equal-sized cells with varying spans rather than true masonry, CSS Grid with grid-auto-rows and grid-row: span N on individual items can be a cleaner fit. That's closer to what you'd use for a bento grid layout — different tool, different problem.

Handling Dynamic Content and Image Loading

This is where most tutorials lie to you. They show a clean static array of items and declare victory. Real masonry in production means items arriving from an API, images of unknown dimensions, and skeleton states that swap out for real content. The CSS-columns approach handles all of this transparently — the browser reflows automatically. The JS approach does not.

For JS masonry with dynamic content, the pattern that works is a combination of key changes to force remounts and image onLoad events to trigger recalculation:

// Force recalc when images inside cards load
function MasonryCard({ src, children }: { src: string; children: React.ReactNode }) {
  const onLoad = useCallback(() => {
    // Emit a custom event your masonry container listens for
    window.dispatchEvent(new CustomEvent('masonry:recalc'));
  }, []);

  return (
    <div className="card">
      <img src={src} onLoad={onLoad} style={{ aspectRatio: '16/9', objectFit: 'cover' }} />
      {children}
    </div>
  );
}

Alternatively — and this is the cleaner solution — always set aspect-ratio on your image containers. If you know your images are 4:3 or 16:9, reserve that space upfront and you eliminate the layout-shift-after-load problem entirely. The masonry grid measures the right height on first render, images load into the pre-reserved space, nothing shifts.

Look, the real-world answer for 80% of projects is: use CSS columns for your gallery, set aspect-ratio on images, add break-inside: avoid on cards, and ship it. You'll spend less time debugging than you would building a custom JS measurer.

Performance and Accessibility Trade-offs Side by Side

Let's be direct about the numbers. CSS columns: zero JavaScript overhead, no ResizeObserver, no layout thrashing. JS masonry: one synchronous layout read per item on every resize — that's O(n) offsetHeight reads per breakpoint change. At 20 items you won't notice. At 200 items on a cheap Android you will.

On the accessibility side, CSS columns has the DOM-order problem described earlier. Tab order goes column-by-column, which can be disorienting. You can partially fix this with CSS order on flex children if you restructure the markup, but it's awkward. JS masonry lets you keep DOM order matching visual order (leftmost item first, then next, then next) which makes tab order predictable. That's a real win.

For screen readers, neither approach is magic. What matters is your HTML semantics inside the grid — meaningful headings, alt text on images, aria-label on interactive elements. The masonry wrapper is presentational. Don't overthink it.

One thing worth testing: will-change: transform on the absolute-positioned JS masonry items. On some GPUs it improves scroll performance, on others it wastes memory by promoting every card to its own compositing layer. Test on real devices at 24px gap (which is what most designs default to) before committing to it. You can also browse the Empire UI, which ships several grid-based layout components already optimised for paint performance — useful to see what the baseline should look like.

Choosing the Right Approach for Your Project

Here's the honest decision tree. Static or near-static content with images of a consistent aspect ratio? CSS columns. Done. Server-rendered page where JS bundle size matters? CSS columns. Dynamic content with variable async heights, items that need to be in DOM order, or lists exceeding 100 items where you need virtualisation? JS masonry — either hand-rolled or via masonic.

What about native CSS masonry? Chrome shipped it behind chrome://flags/#enable-experimental-web-platform-features in late 2025, but it's not in stable as of this writing in September 2026. Firefox has had it behind a flag since 2023. The spec syntax is elegant — grid-template-rows: masonry plus grid-template-columns: repeat(3, 1fr) — but you can't ship flag-required features to production users. Yet. When it lands, CSS columns becomes the migration path: the visual result is nearly identical and the code cleanup is trivial.

If you're building something more styled — cards with glassmorphism, neobrutalist borders, or dark-mode-sensitive shadows — wrapping any masonry approach inside a component system makes future swaps painless. Empire UI's gradient generator and box shadow generator are useful for dialing in card aesthetics without bouncing between browser DevTools and your editor.

The bottom line: don't pick your masonry strategy based on what looks clever in a tutorial. Pick it based on your content. Most projects need CSS columns. A few need JS. Almost none need a custom JS implementation when masonic exists and is 3.5 kB gzipped.

FAQ

Does native CSS masonry work in browsers yet?

Not in stable browsers as of September 2026. It's behind experimental flags in Chrome and Firefox. Use CSS columns or a JS library for production work.

What's the main downside of CSS columns for masonry?

DOM order goes column-by-column instead of row-by-row. This makes keyboard tab order jump down each column before moving right, which can be confusing for keyboard users.

When should I use masonic instead of react-masonry-css?

When you have more than roughly 100–150 items. Masonic virtualises the list so only visible items are in the DOM, keeping scroll performance fast on large datasets.

How do I stop masonry layouts breaking when images load?

Set a fixed aspect-ratio on your image containers so the browser reserves space before the image loads. This eliminates the layout shift that breaks height calculations in JS masonry.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Image Gallery in React: Masonry Grid, Lightbox and Lazy LoadResizable Panels in React: Split View, Drag to ResizePortfolio Layout in Tailwind: Grid, Project Cards, About SectionMasonry Layout in Tailwind: columns-* and the Pinterest Grid