DEV Community

Mohamed Idris
Mohamed Idris

Posted on

How I Fixed My Masonry Layout on Mobile Using window.matchMedia

Have you ever built a nice two-column layout, only to realize it looks broken on mobile?

That happened to me. Here's what went wrong and how I fixed it — step by step.


The Problem

I had a Kudos page — a wall of feedback cards from colleagues. On desktop, I wanted a masonry layout (two columns, cards stacked like bricks). On mobile, just a simple list — one card per row.

My first approach: split the cards into two arrays (even/odd) and render them as two columns.

const columns = [
  kudos.filter((_, i) => i % 2 === 0), // left column
  kudos.filter((_, i) => i % 2 === 1), // right column
];
Enter fullscreen mode Exit fullscreen mode

This gives a nice masonry effect:

Desktop (md+)
┌─────────┐  ┌─────────┐
│ Card 0  │  │ Card 1  │
│         │  └─────────┘
└─────────┘  ┌─────────┐
┌─────────┐  │ Card 3  │
│ Card 2  │  │         │
└─────────┘  │         │
             └─────────┘
Enter fullscreen mode Exit fullscreen mode

Reading order: 0 → 1 → 2 → 3. Correct!

But on mobile, these two column divs stack vertically. So you get:

Mobile (broken)
┌─────────┐
│ Card 0  │  ← left column first
├─────────┤
│ Card 2  │
├─────────┤
│ Card 4  │
├─────────┤
│ Card 1  │  ← then right column
├─────────┤
│ Card 3  │
└─────────┘
Enter fullscreen mode Exit fullscreen mode

Cards are out of order. Not great.


Attempt 1: Render Everything Twice

My first fix was to render two versions — one for mobile, one for desktop — and toggle with CSS:

{/* Mobile: simple list */}
<div className="flex flex-col gap-5 md:hidden">
  {kudos.map(kudo => <KudoCard kudo={kudo} />)}
</div>

{/* Desktop: masonry */}
<div className="hidden md:flex gap-5">
  {columns.map(col => (
    <div className="flex-1 flex flex-col gap-5">
      {col.map(kudo => <KudoCard kudo={kudo} />)}
    </div>
  ))}
</div>
Enter fullscreen mode Exit fullscreen mode

This works visually! But there's a problem:

Every card exists twice in the DOM. One copy is hidden, but it's still there. That's wasted memory and DOM nodes for no reason.


Attempt 2: CSS Columns

CSS has a built-in masonry-like feature: columns.

<div className="columns-1 md:columns-2 gap-5">
  {kudos.map(kudo => (
    <div className="break-inside-avoid">
      <KudoCard kudo={kudo} />
    </div>
  ))}
</div>
Enter fullscreen mode Exit fullscreen mode

Single render, responsive, clean. But...

CSS columns fills top to bottom per column, not left to right:

CSS columns reading order
┌─────────┐  ┌─────────┐
│ Card 0  │  │ Card 3  │
├─────────┤  ├─────────┤
│ Card 1  │  │ Card 4  │
├─────────┤  ├─────────┤
│ Card 2  │  │ Card 5  │
└─────────┘  └─────────┘
Enter fullscreen mode Exit fullscreen mode

You read 0, 1, 2... then jump to 3, 4, 5. The reading order is broken on desktop. Back to square one.


The Fix: window.matchMedia

The solution: use JavaScript to detect the screen size and render only one layout at a time.

What is window.matchMedia?

It's like CSS @media queries, but in JavaScript.

const result = window.matchMedia('(min-width: 768px)');

result.matches;  // true or false right now
result.addEventListener('change', callback);  // fires when it changes
Enter fullscreen mode Exit fullscreen mode

That's it. You give it a media query string, and it tells you:

  • Does it match right now?.matches
  • Tell me when it changes.addEventListener('change', ...)

Using it in React

const MD_BREAKPOINT = '(min-width: 768px)';

function Kudos() {
  const [isMd, setIsMd] = useState(
    () => window.matchMedia(MD_BREAKPOINT).matches,
  );

  useEffect(() => {
    const mql = window.matchMedia(MD_BREAKPOINT);
    const onChange = (e) => setIsMd(e.matches);
    mql.addEventListener('change', onChange);
    return () => mql.removeEventListener('change', onChange);
  }, []);

  return isMd ? <MasonryLayout /> : <SimpleList />;
}
Enter fullscreen mode Exit fullscreen mode

Here's what happens:

  1. On mount — check if the screen is >= 768px
  2. Set up a listener — when the user resizes across 768px, update state
  3. Clean up — remove the listener when the component unmounts
  4. Render one layout — masonry or simple list, never both

The variable name mql

You'll see mql a lot in code. It stands for Media Query List — that's the type of object matchMedia() returns. It's a common shorthand, like e for event or el for element.


The Final Code

const MD_BREAKPOINT = '(min-width: 768px)';
const reversed = [...kudos].reverse();
const columns = [
  reversed.filter((_, i) => i % 2 === 0),
  reversed.filter((_, i) => i % 2 === 1),
];

function Kudos() {
  const [isMd, setIsMd] = useState(
    () => window.matchMedia(MD_BREAKPOINT).matches,
  );

  useEffect(() => {
    const mql = window.matchMedia(MD_BREAKPOINT);
    const onChange = (e) => setIsMd(e.matches);
    mql.addEventListener('change', onChange);
    return () => mql.removeEventListener('change', onChange);
  }, []);

  return (
    <>
      {isMd ? (
        <div className="flex gap-5">
          {columns.map((col, i) => (
            <div key={i} className="flex-1 flex flex-col gap-5">
              {col.map((kudo) => (
                <KudoCard key={kudo.id} kudo={kudo} />
              ))}
            </div>
          ))}
        </div>
      ) : (
        <div className="flex flex-col gap-5">
          {reversed.map((kudo) => (
            <KudoCard key={kudo.id} kudo={kudo} />
          ))}
        </div>
      )}
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

Notice that reversed and columns are outside the component. Since kudos is static data that never changes, there's no reason to recompute these on every render.


Quick Comparison

Approach Correct Order Single DOM Masonry
CSS flex-col / flex-row Mobile breaks Yes Yes
Render twice + hide Yes No (2x nodes) Yes
CSS columns Desktop breaks Yes Yes
matchMedia Yes Yes Yes

Key Takeaways

  • CSS columns fills top-to-bottom, not left-to-right. Great for text, tricky for ordered content.
  • Rendering twice and hiding with CSS works but doubles your DOM nodes.
  • window.matchMedia lets you use media queries in JS — render different layouts without duplication.
  • Hoist static computations outside your component. If the data never changes, don't recompute it on every render.

Top comments (0)