A masonry layout — where cards snap to the top of the shortest column rather than being constrained to a grid row — is one of those features that sounds simple and turns out to be surprisingly annoying to build well.
It's everywhere: Pinterest, image galleries, article feeds, dashboard widgets. But the implementation story is messy.
Why CSS Masonry Isn't Ready
CSS Grid has had a masonry proposal for a while. The syntax looks like this:
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-rows: masonry;
}This would tell the browser to pack items into the grid column-by-column, filling gaps, rather than enforcing row alignment. It's exactly what we want.
The problem: it's still not widely supported. As of early 2026, it's only available behind a flag in Firefox, and the spec discussion is still ongoing in the CSS Working Group. The proposal has been split into a new CSS property called align-tracks, and there's ongoing debate about whether masonry should be a value of grid-template-rows or a separate display value entirely.
There's no reliable timeline for cross-browser support. You can't ship it in production today without a polyfill.
The fallback most people reach for: CSS columns. This actually works and has great browser support, but it flows content top-to-bottom within each column, not across columns. That means items in the left column appear before items in the right column regardless of the natural order — which breaks semantic HTML and can confuse screen readers.
So, for now, if you want a true masonry layout (items ordered left-to-right, packed into shortest column), you need JavaScript.
The Traditional JS Approach — And Why It's Slow
The standard JavaScript masonry implementation looks like this:
function layoutMasonry(container, columns) {
const items = [...container.children]
const colWidth = container.offsetWidth / columns
const colHeights = new Array(columns).fill(0)
items.forEach((item, i) => {
const shortestCol = colHeights.indexOf(Math.min(...colHeights))
item.style.position = 'absolute'
item.style.left = `${shortestCol * colWidth}px`
item.style.top = `${colHeights[shortestCol]}px`
// 💥 This line forces a synchronous layout reflow
colHeights[shortestCol] += item.offsetHeight + GAP
})
}See the problem? item.offsetHeight — reading a layout property — forces the browser to synchronously flush all pending style changes and recompute layout before it can return the value. This is called a forced layout reflow, and it's one of the most common performance anti-patterns in JavaScript.
When you do this in a loop — reading offsetHeight on each item, one by one — each read forces a reflow of the entire document. For 50 cards, that's 50 forced reflows. On a mid-range device, this is visibly janky.
You can partially mitigate this by batching reads first, then writing:
// Better: read all heights first
const heights = items.map(item => item.offsetHeight) // one reflow
// Then write positions
items.forEach((item, i) => {
// assign positions using pre-measured heights...
})But this still requires all items to be in the DOM before you measure them — which means users see an unstyled layout flash before the masonry positions are applied. And any time the container width changes (resize, sidebar toggle), you have to re-measure, which means DOM access again.
Pretext to the Rescue
Pretext is a JavaScript library that measures text height using canvas.measureText() rather than DOM layout. The key insight: if you know the text content, font, and container width, you can calculate the exact rendered height without ever inserting the element into the DOM.
The API is two calls:
import { prepare, layout } from '@chenglou/pretext'
// Measure text once (uses canvas internally)
const prepared = prepare(text, '16px Inter', { whiteSpace: 'pre-wrap' })
// Compute layout — pure arithmetic, no DOM
const { height, lineCount } = layout(prepared, containerWidth, lineHeight)prepare() does the canvas measurement work. layout() is pure math — fast enough to call in a tight loop with no performance concern.
For a masonry grid, this means we can calculate the height of every card before any of them are rendered, then assign positions immediately. No layout flash. No reflow loop. No resize observer gymnastics.
Building the Masonry Component
Here's the complete implementation. The key is calculating heights pre-render, then using absolute positioning:
import { prepare, layout as pretextLayout } from '@chenglou/pretext'
const CARD_GAP = 16
const CARD_PADDING = 32 // total vertical padding per card
const TITLE_FONT = 'bold 16px system-ui, sans-serif'
const EXCERPT_FONT = '14px system-ui, sans-serif'
const TITLE_LINE_HEIGHT = 24
const EXCERPT_LINE_HEIGHT = 22
function computeMasonryLayout(cards, columnCount, containerWidth) {
const colWidth = Math.floor(
(containerWidth - CARD_GAP * (columnCount - 1)) / columnCount
)
const innerWidth = colWidth - CARD_PADDING * 2
// Step 1: measure all cards with pretext — zero DOM access
const cardHeights = cards.map(card => {
const titlePrepared = prepare(card.title, TITLE_FONT)
const excerptPrepared = prepare(card.excerpt, EXCERPT_FONT)
const { height: titleHeight } = pretextLayout(
titlePrepared, innerWidth, TITLE_LINE_HEIGHT
)
const { height: excerptHeight } = pretextLayout(
excerptPrepared, innerWidth, EXCERPT_LINE_HEIGHT
)
return CARD_PADDING + titleHeight + 12 + excerptHeight
})
// Step 2: greedy bin-packing — assign each card to shortest column
const colHeights = new Array(columnCount).fill(0)
const colItems = Array.from({ length: columnCount }, () => [])
cards.forEach((card, i) => {
const shortest = colHeights.indexOf(Math.min(...colHeights))
colItems[shortest].push({
card,
height: cardHeights[i],
top: colHeights[shortest],
})
colHeights[shortest] += cardHeights[i] + CARD_GAP
})
return { colItems, colHeights, colWidth }
}Then in your React component, render using absolute positioning:
function MasonryGrid({ cards, columnCount, containerWidth }) {
const { colItems, colHeights, colWidth } = computeMasonryLayout(
cards, columnCount, containerWidth
)
const totalHeight = Math.max(...colHeights)
return (
<div style={{ position: 'relative', height: totalHeight }}>
{colItems.map((col, ci) =>
col.map(({ card, height, top }, ri) => (
<div
key={`${ci}-${ri}`}
style={{
position: 'absolute',
top,
left: ci * (colWidth + CARD_GAP),
width: colWidth,
height,
}}
>
<Card {...card} />
</div>
))
)}
</div>
)
}The container has a fixed height (the tallest column), and every card is absolutely positioned using the coordinates we computed pre-render. No flicker, no reflow loop.
Interactive Demo
Here's the masonry layout running live. Drag the column slider to watch it repack instantly — the computed pretext heights are shown at the bottom of each card:
Notice that the layout computes and applies immediately on column count change, with no visible flash or layout jank.
Performance Comparison
Here's what happens under the hood with each approach when column count changes from 3 to 2:
DOM-based approach:
- Reset all positions
- For each card: read
offsetHeight→ forced reflow → assigntop/left - 9 cards = 9 forced reflows (or 1 if batched, but still requires DOM access)
Pretext approach:
- Call
prepare()for each card's text fields (canvas, no DOM) - Call
layout()for each card (pure math) - Render all cards with computed positions in one pass
The pretext approach never touches layout. It runs entirely off the critical rendering path, and could even run in a Web Worker.
On a modern desktop the difference is subtle. On a low-end mobile device — or a list with 200+ items — the difference between "smooth" and "janky" is stark.
Handling Window Resize
The tricky part of any masonry layout is handling resize. With the DOM-based approach, you need a ResizeObserver and debounced re-measurement. With pretext, you still need to know the new container width — but the measurement itself is instant:
useEffect(() => {
const observer = new ResizeObserver(entries => {
const width = entries[0].contentRect.width
// Recompute layout with new width — pure math, no DOM reads
setLayout(computeMasonryLayout(cards, columnCount, width))
})
observer.observe(containerRef.current)
return () => observer.disconnect()
}, [cards, columnCount])You still need ResizeObserver to detect the new container width — but the expensive part (measuring card heights) no longer requires DOM access. The resize handler just runs pretext math.
Limitations
Be honest about when this approach doesn't fit:
Font loading matters. Pretext uses canvas for measurement. If your font hasn't loaded when prepare() runs, canvas falls back to a system font and measurements will be wrong. Always wait for fonts:
await document.fonts.ready
const heights = cards.map(card => measureCard(card, containerWidth))Cards with images. If your cards contain images with dynamic heights, pretext can't help with that part — you'd need to measure image dimensions separately and add them to the calculated text height. Pretext only measures text.
Very complex card layouts. If your cards have nested flex layouts, computed paddings, or dynamic content beyond text, the height calculation gets complex. The simpler your card structure, the better this approach works.
CSS-only masonry (when available). Once grid-template-rows: masonry ships in all major browsers, use that instead. CSS layout is always faster than JavaScript layout. Pretext is a bridge, not the final solution.
When to Just Use CSS Columns
For editorial content (blog article feeds, long-form layouts) where left-to-right reading order within columns is acceptable, column-count is much simpler:
.masonry {
column-count: 3;
column-gap: 16px;
}
.masonry > * {
break-inside: avoid;
margin-bottom: 16px;
}No JavaScript needed. But items flow top-to-bottom within each column, which means visual order doesn't match DOM order. If that's acceptable for your use case, it's the right choice.
Takeaways
- CSS
grid-template-rows: masonryis the right long-term answer but isn't cross-browser yet - The classic
offsetHeightloop triggers a synchronous layout reflow per card — expensive on large grids - Pretext computes text heights via canvas, giving you exact card heights before any DOM work
- The masonry algorithm itself is straightforward: greedy bin-packing — assign each card to the current shortest column
- Pre-computed heights + absolute positioning = no layout flash, no reflow loop, resize-safe
- Always call
await document.fonts.readybefore measuring, and handle image-height cards separately