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
];
This gives a nice masonry effect:
Desktop (md+)
┌─────────┐ ┌─────────┐
│ Card 0 │ │ Card 1 │
│ │ └─────────┘
└─────────┘ ┌─────────┐
┌─────────┐ │ Card 3 │
│ Card 2 │ │ │
└─────────┘ │ │
└─────────┘
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 │
└─────────┘
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>
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>
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 │
└─────────┘ └─────────┘
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
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 />;
}
Here's what happens:
-
On mount — check if the screen is
>= 768px - Set up a listener — when the user resizes across 768px, update state
- Clean up — remove the listener when the component unmounts
- 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>
)}
</>
);
}
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
columnsfills 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.matchMedialets 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)