Masonry Gallery

Aspect-ratio oracle heights with bounded image prefetch
Remote imagery in a masonry grid: every card reserves its picture's exact 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>
  &lt;!-- 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 }">
      &lt;!-- 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">
        &lt;!-- 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
  • Direction
    vertical
  • Current Item #
    -
  • Rendered Range #
    0:0
  • DOM Items #
  • Total Size (px)
    0w ×0h
  • Viewport Size (px)
    0w ×0h
  • Scroll Offset (px)
    0x ×0y