Masonry Gallery
aspect-ratio space through the height oracle, only the cards around the scroll position are mounted, and after each scroll settles a small low-priority batch beyond the window is prefetched - images on screen always come first. How to build a feature like this
A masonry gallery streams a long feed of remote images through one scroll container. The masonry engine derives the column count from the container width, greedy-places each card on the shortest column, and mounts only the window around the scroll position - so the DOM stays bounded no matter how long the feed. The key decision for images is the canonical height oracle: every height is a pure function of the item and the resolved column width, which makes the layout exact without ever measuring the DOM. When the card is a picture whose aspect ratio is known, the oracle reserves precisely the space the image will occupy, so the load - which is deferred until the card is actually in the window - cannot shift the layout.
1. Choose the layout mode, then feed geometry props
<VirtualScrollMasonry> renders its own scrollable host (vertical axis only) and needs a definite height, exactly like <VirtualScroll>. The column layout is responsive by construction: the engine picks the largest column count whose target-column-width + gap cadence fits the container width - resolved columns come out at least the target width - clamped by min-columns / max-columns, with gap between cards. Two layout modes exist: the default canonical mode (heights from the oracle, nothing measured) for cards whose size is knowable ahead of time - images with an aspect ratio, fixed-size media - and measured-heights for cards that must size to their own content (wrapping text, user-generated posts), where mounted cards are measured with a ResizeObserver and the oracle height only serves as the pre-measure minimum.
2. Give heights through a deterministic oracle
In canonical mode the item slot must render at exactly the height the oracle returns. The oracle is a function (item, index, columnWidth) → px, and it must be deterministic: the same (index, columnWidth) always yields the same height, so far scrollToIndex jumps land exactly without ever mounting the path. A gallery maps onto this directly: store the asset's natural aspect ratio in the model and return width / aspect, the exact box the image needs. Fetch URLs that are deterministic (same seed + size → same picture) so a recycled card re-shows from the browser cache instead of flickering.
// The model stores only what layout needs to know. Each photo knows its
// natural aspect ratio (width / height); a deterministic id hash supplies the
// pseudo-random variety so the same seed always yields the same layout.
interface GalleryItem {
id: number;
aspect: number; // width / height, from the real asset
}
function makeItems(count: number): GalleryItem[] {
return Array.from({ length: count }, (_, id) => ({
id,
aspect: 0.66 + ((((id * 2654435761) >>> 0) % 1000) / 1000) * 0.95,
}));
}
const items = ref<GalleryItem[]>(makeItems(600));
// Instance ref: bound via ref="masonryRef"; exposes the resolved columnWidth
// (and scrollToIndex/scrollDetails) for the prefetch step below.
const masonryRef = ref<VirtualScrollMasonryInstance<GalleryItem> | null>(null);
// Canonical height oracle: a pure (item, columnWidth) -> height function.
// The engine resolves the column width, asks this oracle for every height,
// and lays out from the oracle - it never mounts the path to
// measure it. It MUST be deterministic: the same (index, width) always
// returns the same height, or placements would be inconsistent.
function itemHeight(item: GalleryItem | undefined, _index: number, width: number): number {
// The picture fills its column, so its reserved height is width / aspect.
// Exact reservation means loading the image never changes the layout.
return Math.max(64, Math.round(width / (item?.aspect ?? 1)));
}
// Deterministic remote source: same seed and size always return the same
// picture, so a recycled row renders from the browser cache.
const imageUrl = (item: GalleryItem, width: number) =>
`https://picsum.photos/seed/vs-${item.id}/${Math.round(width)}/${Math.round(width / item.aspect)}`;3. Render cards that reserve the image box
The engine sizes each card's box (column width × oracle height) before your slot runs, so the slot root must fill it one-to-one (width/height: 100%). Put the picture inside as an absolutely positioned, object-fit: cover image with explicit width/height attributes matching the oracle, and keep it hidden until its bytes arrive. Load state belongs model-side - recycled rows unmount and remount, so per-card flags stored in the DOM would be lost; push ids into reactive arrays from @load/@error and derive visibility from them. Never use native loading="lazy": the window is already the only mounted content, and browser lazy-load heuristics fight the changing scroll container.
<template>
<!-- The component derives the column count from its own width (columns land
as close as possible to target-column-width, clamped by min/max),
greedy-places each card on the shortest column, and mounts only the
window around the scroll position. One scroll container, vertical only. -->
<VirtualScrollMasonry
ref="masonryRef"
class="gallery"
:items="items"
:item-height="itemHeight"
:target-column-width="260"
:min-columns="2"
:max-columns="7"
:gap="12"
aria-label="Gallery"
>
<template #item="{ item, index, width }">
<!-- The engine sized this box (column width x oracle height): the card
fills it 1:1, so the reserved aspect space IS the layout. -->
<div class="card">
<!-- v-show (not v-if) keeps the element mounted and hidden until the
bytes arrive; the box never changes size, so there is no reflow.
The explicit width/height attributes match the oracle exactly. -->
<img
v-show="loadedIds.includes(item.id)"
:src="imageUrl(item, width)"
:width="Math.round(width)"
:height="Math.round(width / item.aspect)"
alt=""
decoding="async"
loading="eager"
@load="onImageLoad(item)"
@error="onImageError(item)"
/>
<span class="badge">#{{ index }}</span>
</div>
</template>
</VirtualScrollMasonry>
</template>
<style scoped>
.gallery {
height: 480px;
} /* the scroll viewport needs a definite height */
.card {
position: relative;
width: 100%;
height: 100%; /* fill the oracle-sized box exactly */
overflow: hidden;
border-radius: 8px;
background: color-mix(in oklab, currentColor 15%, transparent);
}
.card img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.badge {
position: absolute;
inset-block-end: 4px;
inset-inline-end: 4px;
}
</style>4. Prefetch the next images after a scroll settle
Visible cards load eagerly at mount, so revealed cards are already filled. To make the next screen ready too, warm a small batch of images beyond the rendered window - but only after the scroll settles, and only up to a bounded count, or a fast fling fires dozens of request batches. The @scroll event hands you a MasonryScrollDetails payload whose range tells you the rendered window; the instance (ref) exposes the current columnWidth. Request the prefetch at that same width so the browser cache serves the future card. Prefetching is optional: without it, images load when their cards enter the window.
// Bounded, low-priority prefetch. Visible cards are already loading eagerly
// (they are the only mounted content), so this only warms the images beyond
// the rendered window - and only after the scroll settles, so a fast
// fling does not fire dozens of batches.
const PREFETCH_BATCH = 6; // max requests per settle
const PREFETCH_HORIZON = 12; // how far past the window to look
let prefetchTimer: ReturnType<typeof setTimeout> | undefined;
function handleScroll(details: MasonryScrollDetails<GalleryItem>) {
scrollDetails.value = details; // re-scheduled on every emission
schedulePrefetch();
}
function schedulePrefetch() {
clearTimeout(prefetchTimer);
prefetchTimer = setTimeout(() => {
const range = scrollDetails.value?.range; // rendered window (start/end)
const width = masonryRef.value?.columnWidth ?? 0; // current column width
if (!range || !(width > 0)) {
return;
}
const known = new Set([ ...prefetchedIds.value, ...loadedIds.value, ...failedIds.value ]);
let issued = 0;
for (let i = range.end; i <= range.end + PREFETCH_HORIZON && issued < PREFETCH_BATCH; i++) {
const candidate = items.value[i];
if (!candidate || known.has(candidate.id)) {
continue;
}
const img = new Image();
img.decoding = 'async';
img.onload = () => {
if (!prefetchedIds.value.includes(candidate.id)) {
prefetchedIds.value.push(candidate.id);
}
};
img.src = imageUrl(candidate, width); // same width as the card will use
known.add(candidate.id);
issued++;
}
}, 120);
} One scale caveat: the masonry component is vertical-only and does not apply coordinate scaling, so the total content height must stay below the browser's ~10M px scroll limit. For image galleries that is rarely a constraint - thousands of aspect-reserved cards fit in that budget - but beyond it, a scaled VirtualScroll layout is the tool.
- Scroll Status
- Directionvertical
- Current Item #-
- Rendered Range #0:0
- DOM Items #—
- Total Size (px)0w ×0h
- Viewport Size (px)0w ×0h
- Scroll Offset (px)0x ×0y