Photo Gallery

Virtualized image grid with loading placeholders
A high-performance grid gallery displaying 2,000 photos. Placeholders show while images load as their rows enter the viewport.

How to build a feature like this

Virtualizing an image grid runs into one obstacle: a photo's rendered height is unknown until it loads and differs from image to image. The clean way around it is structural rather than measured - decide up front what the engine treats as an item, and give every item a geometry that does not depend on the network. In practice that means making one virtualization item a whole grid row of photos (so only a single vertical axis scrolls and a row mounts atomically), and forcing each cell to a fixed aspect-ratio box that an object-fit: cover image crops to fill. Row height then equals cell width and is identical for every row, so nothing shifts when an image lands. With that in place you choose the sizing model: rows can be measured automatically with ResizeObserver, or - when the container width is known - sized arithmetically with a numeric item-size.

1. Decide your virtualization unit: a row, not a photo

The scroll range counts items. If each photo were its own item, a wide grid would need two scroll axes and a row could end up half-mounted. The simpler, common approach is to group one row's photos into a single item and virtualize over ceil(photoCount / columns) row items; each mounted item paints all its cells with CSS grid. Choose this when your grid has a fixed column count and uniform rows. If you truly need images flowing across both axes independently, the library offers a bidirectional direction="both" grid with column-count/column-width, or the dedicated VirtualScrollMasonry component for a real unequal-height masonry layout - reserve hand-rolled cell math for cases neither covers.

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

const columns = 5;
const photoCount = 2000;

// One ITEM per grid ROW: virtualization ranges over rows, not photos, so the
// DOM stays tiny even though each mounted row paints `columns` <img> cells.
const rows = computed(() => {
  const rowCount = Math.ceil(photoCount / columns);
  return Array.from({ length: rowCount }, (_, r) =>
    Array.from({ length: columns }, (_, c) => ({
      id: r * columns + c,
      thumb: `https://picsum.photos/seed/${r * columns + c}/400`,
      author: `Photographer ${r * columns + c}`,
    })),
  );
});
</script>

2. Give every cell a box that is independent of the network

The engine positions rows from their block size; if that size changed when an image arrived, the rows below would jump. So reserve a fixed, ratio-locked box per cell before the image loads: aspect-ratio: 1 (any ratio works) with overflow: hidden and a neutral background that doubles as the loading placeholder, then fill it with width/height: 100% and object-fit: cover so any source aspect ratio is cropped to the box. Because every grid column is an equal 1fr, all cells in a row share one width and every row is exactly as tall as a cell - the block size is uniform across the grid and no photo can shift its neighbours, whether it is loading, loaded, or scrolled back in from the browser's image cache. Inter-row spacing belongs to the library's :gap prop (part of its scroll math, default 0); the column gap inside a row is ordinary CSS (column-gap) on the grid.

.gallery {
  height: 100%;
} /* needs a definite viewport in a flex/grid parent */
.grid-row {
  display: grid; /* columns are set inline: repeat(N, 1fr) */
  column-gap: 1rem; /* horizontal spacing is plain CSS */
}
.cell {
  aspect-ratio: 1; /* square box reserved per cell - no layout shift */
  border-radius: 0.5rem;
  overflow: hidden;
  background: #ececec; /* loading placeholder behind the image */
}
.cell img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover; /* crops any source ratio to fill the square */
}

3. Size the rows: measured, or arithmetic

With every row the same height you have two valid sizing routes. Leave item-size unset (or set it to 0/null) and each mounted row is measured with ResizeObserver - robust when the container is responsive or the column count can change at runtime, because a re-measured row re-flows only its local range. Alternatively, if the container width is known and stable, derive the row height yourself (cell width = grid width ÷ columns, which equals row height for aspect-ratio: 1) and pass it as a numeric item-size for O(1) arithmetic sizing - with the caveat that you must keep that number in sync (recompute on resize or column changes). The #item slot receives the row array; bind the grid's columns inline and iterate the row's photos into cells.

<template>
  <VirtualScroll
    class="gallery"
    :items="rows"
    :gap="16"
    aria-label="Photo gallery"
  >
    <template #item="{ item: row }">
      <div class="grid-row" :style="{ gridTemplateColumns: `repeat(${columns}, 1fr)` }">
        <figure v-for="photo in row" :key="photo.id" class="cell">
          <img :src="photo.thumb" :alt="photo.author" />
        </figure>
      </div>
    </template>
  </VirtualScroll>
</template>
Grid Columns5
Photo by Photographer 0
Photographer 0
Photo by Photographer 1
Photographer 1
Photo by Photographer 2
Photographer 2
Photo by Photographer 3
Photographer 3
Photo by Photographer 4
Photographer 4
Photo by Photographer 5
Photographer 5
Photo by Photographer 6
Photographer 6
Photo by Photographer 7
Photographer 7
Photo by Photographer 8
Photographer 8
Photo by Photographer 9
Photographer 9
Photo by Photographer 10
Photographer 10
Photo by Photographer 11
Photographer 11
Photo by Photographer 12
Photographer 12
Photo by Photographer 13
Photographer 13
Photo by Photographer 14
Photographer 14
Photo by Photographer 15
Photographer 15
Photo by Photographer 16
Photographer 16
Photo by Photographer 17
Photographer 17
Photo by Photographer 18
Photographer 18
Photo by Photographer 19
Photographer 19
Photo by Photographer 20
Photographer 20
Photo by Photographer 21
Photographer 21
Photo by Photographer 22
Photographer 22
Photo by Photographer 23
Photographer 23
Photo by Photographer 24
Photographer 24
  • Scroll Status
  • Direction
    vertical
  • Current Item #
    -
  • Rendered Range #
    0:0
  • Total Size (px)
    0w ×0h
  • Viewport Size (px)
    0w ×0h
  • Scroll Offset (px)
    0x ×0y