Masonry Layout #
Transform your virtual list into a Pinterest-style masonry layout with the withMasonry feature.
Overview #
The withMasonry feature converts a linear virtual list into a masonry/Pinterest-style layout where items flow into the shortest column (or row in horizontal mode). Unlike grid layouts with aligned rows, masonry creates an organic, packed appearance with no wasted space.
What It Does #
- Shortest-Lane Placement — Items automatically flow into the shortest column/row
- Variable Heights — Each item can have a different size, creating organic layouts
- Scroll-Based Virtualization — Only visible items are rendered based on scroll position
- Auto-Responsive — Column/row width adjusts automatically on container resize
- Gap Support — Configurable spacing between items (horizontal and vertical)
- Memory Efficient — Virtualization keeps DOM nodes minimal
Key Features #
- ✅ Builder-Based — Composable via
vlist().use(withMasonry())API - ✅ Orientation-Agnostic — Vertical (default) and horizontal masonry layouts
- ✅ Dynamic Sizing — Variable item heights/widths for packed layouts
- ✅ Cached Placements — O(1) position lookups after initial O(n) calculation
- ✅ Selection Support — Works with
withSelectionfor selectable items - ✅ Scrollbar Support — Works with
withScrollbarfor custom scrollbars
Key Differences from Grid #
| Aspect | Grid | Masonry |
|---|---|---|
| Layout | Row-based alignment | Shortest-lane flow |
| Virtualization | By rows (O(1)) | By scroll position |
| Positioning | Row/col calculations | Cached x/y coordinates |
| Item placement | Sequential in rows | Dynamic to shortest lane |
| Visual | Aligned rows | Organic, packed |
Quick Start #
import { vlist, withMasonry } from '@floor/vlist'
const gallery = vlist({
container: '#gallery',
item: {
height: (index) => photos[index].height, // Variable heights
template: (item) => `
<div class="card">
<img src="${item.url}" alt="${item.title}" loading="lazy" />
<h3>${item.title}</h3>
</div>
`,
},
items: photos,
})
.use(withMasonry({ columns: 4, gap: 8 }))
.build()
HTML Structure #
<div id="gallery" style="height: 600px;"></div>
Result #
The masonry feature will:
- Calculate column width:
(containerWidth - (columns - 1) * gap) / columns - Track the height of each column
- For each item, find the shortest column and place the item there
- Cache all item positions (x, y coordinates)
- Render only items visible in the viewport
- Add data attribute:
data-laneto each item (column index) - Add
.vlist--masonryclass to the container
Configuration #
Masonry Feature Config #
interface MasonryFeatureConfig {
/** Number of cross-axis divisions (columns in vertical, rows in horizontal) */
columns: number
/** Gap between items in pixels (default: 0) */
gap?: number
}
Example:
const gallery = vlist({
container: '#gallery',
item: {
height: (index) => calculateHeight(items[index]),
template: renderItem,
},
items: photos,
})
.use(withMasonry({ columns: 4, gap: 8 }))
.build()
Item Height Requirements #
Masonry requires item heights to be deterministic (calculable before rendering).
When the height is a function, it receives two parameters — the item index and a context object:
columnWidth— Current column width in pixels (precomputed, updates on resize)containerSize— Current container size in pixels (cross-axis dimension)columns— Number of columnsgap— Gap between items in pixels
Responsive by default: When you use a height function with
columnWidth, item heights automatically recalculate on container resize — no manual intervention needed.
✅ Good — Deterministic Heights #
// Aspect ratio from data — responsive to resize
item: {
height: (index, { columnWidth }) => Math.round(columnWidth * photos[index].aspectRatio),
template: renderPhoto,
}
// Fixed pixel heights from data
item: {
height: (index) => photos[index].height,
template: renderPhoto,
}
// Fixed categories
item: {
height: (index) => items[index].type === 'large' ? 400 : 200,
template: renderItem,
}
❌ Bad — Non-Deterministic Heights #
// Dynamic content that requires measuring
item: {
estimatedHeight: 200, // This uses auto-measurement, won't work with masonry!
template: renderDynamicContent,
}
// Heights dependent on render
item: {
height: 200, // Fixed height but content varies - will cause layout issues
template: (item) => `<div>${item.longText}</div>`, // Text might overflow
}
Why? Masonry pre-calculates all item positions before rendering. It needs to know each item's size upfront to determine which column is shortest.
Orientation Support #
Masonry works in both vertical and horizontal orientations:
Vertical Masonry (Default) #
const gallery = vlist({
container: '#gallery',
orientation: 'vertical', // Default
item: {
// columnWidth adapts on resize — aspect ratios preserved
height: (index, { columnWidth }) => Math.round(columnWidth * photos[index].aspectRatio),
template: renderPhoto,
},
items: photos,
})
.use(withMasonry({ columns: 4, gap: 8 }))
.build()
Layout:
┌─────┬─────┬─────┬─────┐
│ 1 │ 2 │ 3 │ 4 │
│ ├─────┤ ├─────┤
├─────┤ 5 │ │ 7 │
│ 6 │ ├─────┤ │
│ ├─────┤ 8 ├─────┤
│ │ 9 │ │ 10 │
└─────┴─────┴─────┴─────┘
↓ Scroll down
- 4 vertical columns of independent heights
- Items flow into shortest column
- Scrolls vertically (↓)
- The context's
columnWidthis the width of each column
Horizontal Masonry #
const timeline = vlist({
container: '#timeline',
orientation: 'horizontal',
item: {
// width function receives the same context — columnWidth is now each row's height
width: (index, { columnWidth }) => Math.round(columnWidth * events[index].aspectRatio),
height: 200, // Fixed cross-axis size
template: renderEvent,
},
items: events,
})
.use(withMasonry({ columns: 3, gap: 12 }))
.build()
Layout:
┌────┬────────┬──┐
│ 1 │ 4 │ 7│ ← Row 0
├────┼────┬───┼──┤
│ 2 │ 5 │ 6 │ 8│ ← Row 1
├────┼────┴───┼──┤
│ 3 │ 9 │10│ ← Row 2
└────┴────────┴──┘
→ Scroll right
- 3 horizontal rows of independent widths
- Items flow into shortest row
- Scrolls horizontally (→)
- In horizontal mode,
columnscontrols the number of rows, andcolumnWidthin the context is each row's height
The context object works identically in both orientations —
columnWidthalways refers to the cross-axis cell size, adapting automatically on resize.
Note: In horizontal mode, you must specify both height and width in the item config.
Examples #
Pinterest-Style Photo Gallery #
import { vlist, withMasonry } from '@floor/vlist'
const photos = [
{ id: 1, url: 'photo1.jpg', aspectRatio: 0.75, title: 'Sunset' },
{ id: 2, url: 'photo2.jpg', aspectRatio: 1.5, title: 'Mountain' },
{ id: 3, url: 'photo3.jpg', aspectRatio: 0.66, title: 'Ocean' },
// ... more photos with varying aspect ratios
]
const gallery = vlist({
container: '#gallery',
item: {
// Height derived from columnWidth — adapts on resize
height: (index, { columnWidth }) => Math.round(columnWidth * photos[index].aspectRatio),
template: (item) => `
<div class="photo-card">
<img
src="${item.url}"
alt="${item.title}"
loading="lazy"
/>
<div class="photo-overlay">
<h3>${item.title}</h3>
</div>
</div>
`,
},
items: photos,
})
.use(withMasonry({ columns: 4, gap: 12 }))
.build()
Responsive Columns #
const gallery = vlist({
container: '#gallery',
item: {
height: (index, { columnWidth }) => Math.round(columnWidth * photos[index].aspectRatio),
template: renderPhoto,
},
items: photos,
})
.use(withMasonry({ columns: getResponsiveColumns(), gap: 8 }))
.build()
function getResponsiveColumns() {
const width = window.innerWidth
if (width < 640) return 2
if (width < 1024) return 3
if (width < 1536) return 4
return 5
}
// Update column count on resize
window.addEventListener('resize', () => {
gallery.updateMasonry({ columns: getResponsiveColumns() })
})
Note: Item heights using
columnWidthadapt automatically on container resize. You only need to update columns manually if you want breakpoint-based column counts.
Product Catalog with Variable Heights #
const products = [
{ id: 1, name: 'Widget', price: 9.99, image: 'widget.jpg', hasDetails: true },
{ id: 2, name: 'Gadget', price: 19.99, image: 'gadget.jpg', hasDetails: false },
// ...
]
const catalog = vlist({
container: '#catalog',
item: {
height: (index, { columnWidth }) => {
const product = products[index]
// Taller cards for products with details, responsive to column width
return product.hasDetails
? Math.round(columnWidth * 1.4)
: Math.round(columnWidth)
},
template: (item) => `
<div class="product-card">
<img src="${item.image}" alt="${item.name}" loading="lazy" />
<h3>${item.name}</h3>
<p class="price">$${item.price}</p>
</div>
`,
},
items: products,
})
.use(withMasonry({ columns: 3, gap: 16 }))
.build()
Masonry with Selection #
import { vlist, withMasonry, withSelection } from '@floor/vlist'
const gallery = vlist({
container: '#gallery',
item: {
height: (index) => photos[index].height,
template: (item, index, state) => `
<div class="card ${state.selected ? 'selected' : ''}">
<img src="${item.url}" alt="${item.title}" />
${state.selected ? '<div class="checkmark">✓</div>' : ''}
</div>
`,
},
items: photos,
})
.use(withMasonry({ columns: 4, gap: 8 }))
.use(withSelection({ mode: 'multiple' }))
.build()
// Listen for selection changes
gallery.on('selection:change', ({ selectedIndices }) => {
console.log(`Selected ${selectedIndices.length} photos`)
})
Content Feed with Mixed Media #
const feedItems = [
{ id: 1, type: 'text', content: '...', height: 150 },
{ id: 2, type: 'image', url: '...', height: 300 },
{ id: 3, type: 'video', url: '...', height: 400 },
// ...
]
const feed = vlist({
container: '#feed',
item: {
height: (index) => feedItems[index].height,
template: (item) => {
if (item.type === 'text') {
return `<div class="text-post">${item.content}</div>`
}
if (item.type === 'image') {
return `<img src="${item.url}" loading="lazy" />`
}
if (item.type === 'video') {
return `<video src="${item.url}" controls></video>`
}
},
},
items: feedItems,
})
.use(withMasonry({ columns: 3, gap: 16 }))
.build()
API Reference #
withMasonry(config) #
Creates a masonry feature for the builder.
Parameters:
config.columns(number, required) — Number of cross-axis divisions (>= 1)config.gap(number, optional) — Gap between items in pixels (default: 0)
Returns: VListFeature — A feature that can be passed to .use()
Example:
import { vlist, withMasonry } from '@floor/vlist'
const list = vlist({
container: '#app',
item: {
height: (index) => items[index].height,
template: renderItem,
},
items: data,
})
.use(withMasonry({ columns: 4, gap: 8 }))
.build()
Built List API #
The masonry feature doesn't add new methods — all standard vlist methods work as expected:
const list = vlist(config)
.use(withMasonry({ columns: 4, gap: 8 }))
.build()
// Standard methods work
list.scrollToIndex(10, 'center') // Scrolls to item 10
list.getViewportState() // Returns viewport state
list.destroy() // Cleanup
Data Attributes #
Masonry items receive a data attribute:
<div class="vlist-item" data-lane="2">
<!-- Your content -->
</div>
data-lane— Cross-axis division index (column in vertical, row in horizontal, 0-based)
Use this for CSS styling or JavaScript logic.
Performance #
Algorithm Complexity #
| Operation | Complexity | Notes |
|---|---|---|
| Layout calculation | O(n) | One-time cost per data change, n = total items |
| Position lookup | O(1) | Cached placements array |
| Visibility check | O(k × log(n/k)) | Per-lane binary search, k = columns |
| Total size | O(1) | Cached during layout calculation |
| Scroll frame (steady) | O(1) | Early exit when position unchanged |
Example with 10,000 items, 4 columns:
- Layout calculation: ~10-20ms (one-time cost)
- Visibility query: ~44 comparisons (vs 10,000 linear scan)
- Steady-state scroll frame: 0 work (early exit)
- Rendering: Only visible items (~20-40 DOM nodes)
Scroll-Frame Optimizations #
The masonry feature is heavily optimized for the scroll hot path — the code that runs on every scroll event:
Zero-allocation steady-state scroll:
- Pooled visible-items array (reused, not allocated per frame)
- Reusable visibility Set for O(1) element recycling decisions
- Cached
getItemclosure (created once at setup) - Cached empty Set for no-selection case
- Viewport state mutated in place (no object creation)
- DocumentFragment batching for new DOM insertions (matches core renderer)
- Release grace period — items kept alive for extra render cycles after leaving the visible set, preventing boundary thrashing (hover state loss, CSS transition replays)
Change tracking in the renderer:
- Template re-evaluation skipped when item id + selection/focus state unchanged
- Position updates skipped when coordinates unchanged
aria-setsizestring conversion cached until total count changes
Early exit guard:
- When scroll position and container size are identical to the previous frame, all downstream work is skipped entirely — no binary search, no renderer diffing, no viewport state updates
Memory Efficiency #
With a 4-column masonry of 1,000 items:
- Without virtualization: 1,000 DOM nodes
- With masonry virtualization: ~40 DOM nodes (visible items only)
- Savings: ~96% fewer DOM nodes
Element pooling recycles DOM elements as items leave the viewport, avoiding createElement costs during fast scrolling.
Comparison with Grid #
| Metric | Grid | Masonry |
|---|---|---|
| Layout calculation | O(1) | O(n) |
| Visibility check | O(1) row math | O(k × log(n/k)) binary search |
| Position lookup | O(1) | O(1) cached |
| Memory | Minimal | Minimal + placement cache |
| Visual alignment | Perfect rows | Organic flow |
| Use case | Uniform content | Variable-height content |
Recommendation: Use grid for uniform content (cards, products), use masonry for variable-height content (photos, articles, feeds).
Styling #
Default CSS Classes #
Masonry adds a modifier class to the container:
.vlist--masonry {
/* Applied when masonry feature is active */
}
.vlist-item {
/* Each masonry item */
position: absolute;
box-sizing: border-box;
}
.vlist-item[data-lane="0"] {
/* First column/row items */
}
Example Styles #
.vlist--masonry .vlist-item {
border-radius: 8px;
overflow: hidden;
transition: transform 0.2s;
}
.vlist--masonry .vlist-item:hover {
transform: scale(1.02);
z-index: 10;
}
.vlist--masonry .vlist-item--selected {
outline: 3px solid #3b82f6;
outline-offset: -3px;
}
.vlist--masonry .vlist-item img {
width: 100%;
display: block;
}
/* Responsive styles */
@media (max-width: 768px) {
.vlist--masonry .vlist-item {
border-radius: 4px;
}
}
Custom Class Prefix #
const gallery = vlist({
container: '#gallery',
classPrefix: 'my-masonry', // Custom prefix
item: {
height: (index) => photos[index].height,
template: renderItem,
},
items: photos,
})
.use(withMasonry({ columns: 4, gap: 8 }))
.build()
Now use .my-masonry--masonry and .my-masonry-item in your CSS.
Best Practices #
1. Use Responsive Heights with Context #
✅ Best — Aspect ratios from data, responsive to resize:
item: {
height: (index, { columnWidth }) => Math.round(columnWidth * items[index].aspectRatio),
template: renderItem,
}
✅ OK — Fixed pixel heights from data (won't adapt on resize):
item: {
height: (index) => items[index].height,
template: renderItem,
}
❌ Bad — Heights dependent on rendering:
item: {
estimatedHeight: 200, // Won't work with masonry!
template: renderDynamicContent,
}
2. Use Appropriate Column Counts #
Choose columns based on your content and viewport:
// Compact - many narrow columns
.use(withMasonry({ columns: 6, gap: 4 }))
// Standard - balanced layout
.use(withMasonry({ columns: 4, gap: 8 }))
// Spacious - fewer wide columns
.use(withMasonry({ columns: 2, gap: 16 }))
3. Optimize Images #
Use lazy loading and proper sizing:
template: (item) => `
<img
src="${item.url}"
loading="lazy"
decoding="async"
alt="${item.title}"
/>
`
4. Consider Container Padding #
Container padding affects column width calculations:
#gallery {
padding: 16px;
box-sizing: border-box; /* Important! */
}
The masonry layout automatically accounts for this if you use box-sizing: border-box.
5. Handle Data Changes #
When items change, the layout is automatically recalculated. All data mutation methods are intercepted:
const gallery = vlist(config)
.use(withMasonry({ columns: 4, gap: 8 }))
.build()
// All of these trigger automatic layout recalculation
gallery.setItems(newPhotos)
gallery.appendItems(morePhotos)
gallery.prependItems(newPhotos)
gallery.updateItem(id, { height: 300 })
gallery.removeItem(id)
6. Store Aspect Ratios, Not Pixel Heights #
When possible, store aspect ratios in your data instead of fixed pixel heights. This lets items scale proportionally when the container resizes:
// ✅ Good — scales with column width
const photos = items.map(item => ({
...item,
aspectRatio: item.height / item.width,
}))
height: (index, { columnWidth }) => Math.round(columnWidth * photos[index].aspectRatio)
// ❌ Fragile — breaks aspect ratio on resize
height: (index) => photos[index].pixelHeight
Limitations #
Cannot Combine With #
❌ Reverse Mode — Masonry doesn't support reverse scrolling
// Invalid combination
vlist({
reverse: true, // ❌ Error!
// ...
})
.use(withMasonry({ columns: 4 }))
❌ Sections — Masonry doesn't currently support grouped layouts
// Not supported (yet)
vlist(config)
.use(withMasonry({ columns: 4 }))
.use(withGroups({ ... })) // Won't work correctly
Item Size Requirements #
- Heights must be deterministic — Calculate before rendering
- No auto-measurement — Cannot use
estimatedHeight - No dynamic content sizing — Avoid relying on CSS to determine height
Troubleshooting #
Items overlap or have wrong spacing #
Check:
- Container uses
box-sizing: border-boxif it has padding - Item heights are correct in your data
- Gap value is not too large for the container size
Layout looks wrong after resize #
Masonry automatically recalculates layout and re-renders on resize. If you're using fixed pixel heights (not columnWidth-based), items will keep their original sizes while columns change width — this can look wrong. Solution: Use the context's columnWidth in your height function:
// Heights adapt automatically on resize
height: (index, { columnWidth }) => Math.round(columnWidth * items[index].aspectRatio)
If issues persist after manual DOM changes:
gallery.forceRender()
Performance issues with many items #
Solutions:
- Reduce overscan value:
overscan: 1 - Ensure item heights are accurate (prevents re-layouts)
- Use smaller images with lazy loading
- Consider pagination or infinite scroll
Note: The masonry feature uses per-lane binary search for visibility checks (O(k × log(n/k)) instead of O(n)), so performance scales well even with tens of thousands of items.
Items not rendering #
Check:
- Container has explicit height:
<div id="gallery" style="height: 600px;"> - Items array is not empty
columnsis a positive integer >= 1- Height function returns valid numbers
Migration from Grid #
Key Differences #
| Aspect | Grid | Masonry |
|---|---|---|
| Import | withGrid |
withMasonry |
| Layout | Aligned rows | Organic flow |
| Heights | Can be uniform | Should vary |
| Best for | Cards, products | Photos, articles |
Example Migration #
Before (Grid):
vlist(config)
.use(withGrid({ columns: 4, gap: 8 }))
.build()
After (Masonry):
vlist({
...config,
item: {
height: (index) => items[index].height, // Add variable heights
template: config.item.template,
},
})
.use(withMasonry({ columns: 4, gap: 8 }))
.build()
Related Documentation #
- Grid Feature — 2D grid with aligned rows
- Selection Feature — Select masonry items
- Scrollbar Feature — Custom scrollbars
- API Reference — Complete API — config, methods, events
Live Examples #
- Photo Album — Grid & masonry layouts with variable heights
Last Updated: February 2026
Version: v1.1.0