Masonry Grid

One scroll container: heights from the model, or measured from the DOM
Masonry in a single scroll container: the column count follows the container width and only the visible cards are mounted, however many there are. Heights come from the model - or flip Measure card heights on: mounted cards are measured and every few ticks two visible cards grow a row, so you can watch the measured layout track the real DOM with the viewport pinned.

How to build a feature like this

Real masonry in a single scroll container: the component derives a responsive column count from its own measured width, places every card greedily on the shortest column, and mounts only the cards around the scroll position - the DOM stays bounded regardless of dataset size. Card heights come from a canonical oracle: a deterministic function of (item, index, columnWidth) that prices every card without touching the DOM, so layouts are reproducible, far jumps land exactly, and the total height is known once the layout chain reaches the end. When content must size itself (wrapped text, media), a measured-heights mode reads mounted cards back with a ResizeObserver instead.

1. Size a single vertical scroll container

VirtualScrollMasonry renders its own host: it fills the width and height you give it and scrolls vertically (overflow-y; there is no horizontal axis, so never lay cards out side by side yourself). Give it a definite height - an explicit value or a flex/grid slot with min-height: 0 - and a width that can change: the container is observed and the column layout reflows responsively on resize. Masonry runs at scale 1 with no coordinate scaling, so keep the total content height under the browser's ~10M px scroll limit.

2. Model the cards and write the height oracle

items is an array of one object per card. The required item-height prop is an oracle: a function (item, index, columnWidth) => px that returns the rendered height of a card at the resolved column width. It must be deterministic - the same (index, columnWidth) must always produce the same height, because placements are committed to a layout chain and replayed from stored snapshots. Derive it from model fields (a line count, an aspect ratio, a stored height); never read the DOM or use Math.random(). Non-finite results fall back to 40 and non-positive values clamp to 1.

// One model object per card; layout-relevant fields drive the oracle.
interface Card {
  id: number;
  hue: number;   // hsl hue, for the card background
  lines: number; // number of body text lines
}

function makeCards(count: number): Card[] {
  return Array.from({ length: count }, (_, id) => ({
    id,
    hue: (id * 137.508) % 360,
    lines: 2 + (id % 4),
  }));
}

// Natural px height of one card at the reference width (240px).
function naturalHeight(card: Card): number {
  return 48 + card.lines * 28;
}

// Canonical height oracle. It MUST be a pure function of
// (item, index, columnWidth): the same inputs always yield the same height,
// because placements are committed to a layout chain and replayed from
// stored snapshots. Never read the DOM here.
function itemHeight(card: Card | undefined, _index: number, width: number): number {
  const estimate = card ? naturalHeight(card) * 1.3 : 200;
  return Math.max(48, Math.round(estimate * (width / 240)));
}

3. Let the container width drive the columns

The column count is derived, not chosen: target-column-width (default 240) is the desired card width, and the component derives the count so columns land as close as possible to it, bounded by min-columns / max-columns (defaults 1 / 10). The resolved column width is fractional so the gutters (gap, default 10, applied between columns and rows) divide the container width exactly. Because heights come from the oracle alone, unvisited regions never need mounting: layout is computed in segments of segment-size items (default 500), so far scrollToIndex targets land on the exact canonical position, and the exposed totalHeightExact flips true once every step down to the last item has been laid out.

4. Render the windowed cards

The #item slot provides { item, index, column, x, y, width, height }. The engine absolutely places each card at (x, y) with the column width and the oracle height - cards are mounted only around the viewport (with overscan), so slot content must be self-contained and derived purely from the model. In canonical mode the wrapper is exactly oracle-height: make the card fill it and guarantee its content never exceeds that height (reserve media space with aspect-ratio or keep the content deterministic), because an overflowing card would overlap the next one.

<script setup lang="ts">
import { VirtualScrollMasonry } from '@pdanpdan/virtual-scroll';
import '@pdanpdan/virtual-scroll/style.css';

const cards = makeCards(10_000);

const WORDS = [ 'amber', 'cobalt', 'dune', 'ember', 'fjord', 'kelp' ];
function lineOf(card: Card, row: number): string {
  return `${ WORDS[ (card.id * 13 + row * 7) % WORDS.length ] } ${ WORDS[ (card.id * 29 + row * 11) % WORDS.length ] }`;
}
</script>

<template>
  <VirtualScrollMasonry
    class="masonry"
    :items="cards"
    :item-height="itemHeight"
    :min-columns="2"
    :max-columns="8"
    :gap="16"
    aria-label="Masonry of cards"
  >
    <template #item="{ item, index, column }">
      <div v-if="item" class="card" :style="{ backgroundColor: `hsl(${ item.hue } 60% 80%)` }">
        <p class="card-title">Card #{{ index }} - col {{ column }}</p>
        <p v-for="row in item.lines" :key="row" class="card-line">{{ lineOf(item, row - 1) }}</p>
      </div>
    </template>
  </VirtualScrollMasonry>
</template>

<style scoped>
/* Definite height; the width is observed and reflows the column count. */
.masonry {
  height: 560px;
  border: 1px solid oklch(50% 0 0 / 0.2);
}

/* Canonical mode: the wrapper is exactly oracle-height, so the card fills it
   and content must never exceed it (reserve media with aspect-ratio or model
   fields). Cards are absolutely placed; overflow would overlap neighbors. */
.card {
  height: 100%;
  box-sizing: border-box;
  overflow: hidden;
  padding: 12px;
}
.card-title {
  margin: 0 0 8px;
  font-weight: 700;
}
.card-line {
  margin: 0;
  font-size: 12px;
  line-height: 20px;
}
</style>

5. Switch to measured heights when only the DOM knows the size

For cards whose real height depends on layout (wrapped text, images, dynamic content), set measured-heights. Mounted cards are then observed and their measured boxes drive the layout: the oracle height becomes the pre-measure minimum and estimate, each measurement batch re-lays-out with the topmost visible card pinned at its screen offset, and the result is deterministic per measurement history. Regions that were never mounted still fall back to the oracle, and the measurements reset when the items array is replaced. After in-place item edits or an oracle change, call the exposed refresh() to drop the cached layout and re-flow from the current anchor. The instance also exposes columns, columnWidth, totalHeight, totalHeightExact, and the scrollToIndex / scrollToOffset methods for programmatic navigation.

Jump to card
  • 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