/ Examples
gridmasonryselectionscrollbarsnapshots

Photo Album

Virtualized 2D photo gallery with real images from Lorem Picsum. Toggle between grid and masonry layouts, adjust columns and gap — only visible rows are rendered.

Photos

0% 0.00 / 0.00 px/ms 0 / 0 items
grid Y
Source
// Photo Album — Virtualized 2D photo gallery
// Demonstrates grid + masonry + scrollbar plugins
// Layout mode toggle: Grid ↔ Masonry

import {
  createVList,
  grid,
  masonry,
  scrollbar,
  selection,
} from "vlist";
import { createStats } from "../../stats.js";
import { createInfoUpdater } from "../../info.js";
import {
  ITEM_COUNT,
  ASPECT_RATIO,
  items,
  itemTemplate,
  currentMode,
  currentOrientation,
  currentColumns,
  currentGap,
  followFocus,
  list,
  setFactory,
  onReady,
  createView,
} from "../shared.js";
import "../controls.js";

const PADDING = 2;

// =============================================================================
// Stats — shared info bar (progress, velocity, visible/total)
// =============================================================================

function getEffectiveItemHeight() {
  const container = document.getElementById("list-container");
  if (!container || !list) return 200;
  const innerWidth = container.clientWidth - PADDING * 2;
  const colWidth =
    (innerWidth - (currentColumns - 1) * currentGap) / currentColumns;
  if (currentMode === "masonry") return Math.round(colWidth * 1.05);
  return Math.round(colWidth * ASPECT_RATIO);
}

export const stats = createStats({
  getScrollPosition: () => list?.getScrollPosition() ?? 0,
  getTotal: () => ITEM_COUNT,
  getItemSize: () => getEffectiveItemHeight(),
  getColumns: () => currentColumns,
  getContainerSize: () => {
    const el = document.querySelector("#list-container");
    if (!el) return 0;
    return currentOrientation === "horizontal"
      ? el.clientWidth
      : el.clientHeight;
  },
});

const updateInfo = createInfoUpdater(stats);

// =============================================================================
// Create / Recreate
// =============================================================================

setFactory(
  (snap) => {
    const container = document.getElementById("list-container");

    if (currentMode === "grid") {
      return createGridView(container, currentOrientation, currentColumns, currentGap, snap);
    }
    return createMasonryView(container, currentOrientation, currentColumns, currentGap, snap);
  },
  { key: "photo-album", transition: { fadeIn: 300, fadeOut: 200, fadeOutDelay: 100 } },
);

onReady((list) => {
  list.on("scroll", updateInfo);
  list.on("range:change", updateInfo);
  list.on("velocity:change", ({ velocity }) => {
    stats.onVelocity(velocity);
    updateInfo();
  });
  list.on("selection:change", ({ items: selected }) => {
    if (selected.length > 0) showDetail(selected[0]);
  });

  updateInfo();
  updateContext();
});

function createGridView(container, orientation, columns, gap, snap) {
  if (orientation === "horizontal") {
    const innerHeight = container.clientHeight - PADDING * 2;
    const colWidth = (innerHeight - (columns - 1) * gap) / columns;
    const height = Math.round(colWidth);

    return createVList(
      {
        padding: PADDING,
        container: "#list-container",
        ariaLabel: "Photo gallery",
        orientation,
        item: {
          height,
          width: (_index, ctx) =>
            ctx ? Math.round(ctx.columnWidth / ASPECT_RATIO) : 200,
          template: itemTemplate,
        },
        items,
      },
      [
        grid({ columns, gap }),
        selection({ mode: "single", followFocus }),
        scrollbar({ autoHide: true }),
        snap,
      ],
    );
  }

  return createVList(
    {
      padding: PADDING,
      container: "#list-container",
      ariaLabel: "Photo gallery",
      orientation,
      item: {
        height: (_index, ctx) =>
          ctx ? Math.round(ctx.columnWidth * ASPECT_RATIO) : 200,
        template: itemTemplate,
      },
      items,
    },
    [
      grid({ columns, gap }),
      selection({ mode: "single", followFocus }),
      scrollbar({ autoHide: true }),
      snap,
    ],
  );
}

function createMasonryView(container, orientation, columns, gap, snap) {
  return createVList(
    {
      container: "#list-container",
      ariaLabel: "Photo gallery",
      orientation,
      padding: PADDING,
      item: {
        height: (_index, ctx) =>
          ctx ? Math.round(ctx.columnWidth * items[_index].aspectRatio) : 200,
        width:
          orientation === "horizontal"
            ? (_index, ctx) =>
                ctx
                  ? Math.round(ctx.columnWidth * items[_index].aspectRatio)
                  : 200
            : undefined,
        template: itemTemplate,
      },
      items,
    },
    [
      masonry({ columns, gap }),
      selection({ mode: "single", followFocus }),
      scrollbar({ autoHide: true }),
      snap,
    ],
  );
}

// =============================================================================
// Photo detail (panel)
// =============================================================================

const detailEl = document.getElementById("photo-detail");

function showDetail(item) {
  detailEl.innerHTML = `
    <img
      class="detail__img"
      src="https://picsum.photos/id/${item.picId}/400/300"
      alt="${item.title}"
    />
    <div class="detail__meta">
      <strong>${item.title}</strong>
      <span>${item.category} · ♥ ${item.likes}</span>
    </div>
  `;
}

// =============================================================================
// Info bar — right side (contextual)
// =============================================================================

const infoMode = document.getElementById("info-mode");
const infoOrientation = document.getElementById("info-orientation");

function updateContext() {
  infoMode.textContent = currentMode;
  infoOrientation.textContent = currentOrientation === "vertical" ? "Y" : "X";
}

// =============================================================================
// Initialise
// =============================================================================

createView();
// Photo Album — Shared data, constants, template, and state
// Imported by all framework implementations to avoid duplication

import { rebuild } from "vlist";

// =============================================================================
// Constants
// =============================================================================

export const ITEM_COUNT = 900;
export let ASPECT_RATIO = 0.75; // 4:3 landscape

const CATEGORIES = [
  "Nature",
  "Urban",
  "Portrait",
  "Abstract",
  "Travel",
  "Food",
  "Animals",
  "Architecture",
  "Art",
  "Space",
];

// Variable aspect ratios for masonry mode (height/width)
const ASPECT_RATIOS = [0.75, 1.0, 1.33, 1.5, 0.66];

// =============================================================================
// Valid Picsum IDs — excludes first 8 and all known 404s
// =============================================================================

const DEAD_IDS = new Set([
  0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 86, 97, 105, 138, 148, 150, 205,
  207, 224, 226, 245, 246, 262, 285, 286, 298, 303, 332, 333, 346, 359, 394,
  414, 422, 438, 462, 463, 470, 489, 540, 561, 578, 587, 589, 592, 595, 597,
  601, 624, 632, 636, 644, 647, 673, 697, 706, 707, 708, 709, 710, 711, 712,
  713, 714, 720, 725, 734, 745, 746, 747, 748, 749, 750, 751, 752, 753, 754,
  759, 761, 762, 763, 771, 792, 801, 812, 843, 850, 854, 895, 897, 899, 917,
  920, 934, 956, 963, 968, 1007, 1017, 1030, 1034, 1046,
]);

const VALID_IDS = [];
for (let id = 0; id <= 1084; id++) {
  if (!DEAD_IDS.has(id)) VALID_IDS.push(id);
}

// =============================================================================
// Data
// =============================================================================

export const items = Array.from({ length: ITEM_COUNT }, (_, i) => {
  const picId = VALID_IDS[i % VALID_IDS.length];
  const category = CATEGORIES[i % CATEGORIES.length];
  return {
    id: i + 1,
    title: `Photo ${i + 1}`,
    category,
    likes: Math.floor(Math.abs(Math.sin(i * 2.1)) * 500),
    picId,
    aspectRatio: ASPECT_RATIOS[i % ASPECT_RATIOS.length],
  };
});

// =============================================================================
// Template
// =============================================================================

export const itemTemplate = (item) => `
  <div class="card">
    <img
      class="card__img"
      src="https://picsum.photos/id/${item.picId}/300/225"
      alt="${item.title}"
      loading="lazy"
      decoding="async"
      data-t="${performance.now()}"
      onload="if(performance.now()-this.dataset.t<100){this.style.transition='none';this.offsetHeight}this.classList.add('card__img--loaded')"
      onerror="this.style.transition='none';this.classList.add('card__img--loaded')"
    />
    <div class="card__overlay">
      <span class="card__title">${item.title}</span>
      <span class="card__category">${item.category}</span>
    </div>
    <div class="card__likes">♥ ${item.likes}</div>
  </div>
`;

// =============================================================================
// State — mutable, shared across script.js and controls.js
// =============================================================================

export let currentMode = "grid";
export let currentOrientation = "vertical";
export let currentColumns = 4;
export let currentGap = 8;
export let currentRadius = 8;
export let followFocus = true;
export let list = null;

export function setCurrentMode(v) {
  currentMode = v;
}
export function setCurrentOrientation(v) {
  currentOrientation = v;
}
export function setCurrentColumns(v) {
  currentColumns = v;
}
export function setCurrentGap(v) {
  currentGap = v;
}
export function setCurrentRadius(v) {
  currentRadius = v;
}
export function setFollowFocus(v) {
  followFocus = v;
}
export function setAspectRatio(v) {
  ASPECT_RATIO = v;
}
export function setList(v) {
  list = v;
}

// =============================================================================
// View lifecycle
// =============================================================================

let _factory = null;
let _rebuildOptions = {};
let _readyCallbacks = [];
let _createViewFn = null;
let _viewVersion = 0;

export function setFactory(fn, options) {
  _factory = fn;
  _rebuildOptions = options || {};
}

export function onReady(fn) {
  _readyCallbacks.push(fn);
}

export function setCreateView(fn) {
  _createViewFn = fn;
}

export async function createView() {
  if (_factory) {
    const version = ++_viewVersion;
    const newList = await rebuild(list, _factory, _rebuildOptions);
    if (version !== _viewVersion) {
      newList.destroy();
      return;
    }
    list = newList;
    for (const fn of _readyCallbacks) fn(list);
  } else if (_createViewFn) {
    _createViewFn();
  }
}
/* Photo Album — shared styles for all variants (javascript, react, vue, svelte)
   Common styles (.container, h1, .description, .stats, footer)
   are provided by example/example.css using shell.css design tokens.
   UI components (.split-layout, .split-panel, .ui-*)
   is also provided by example/example.css. */

.split-main {
    background-color: transparent !important;
}

/* Grid container */
#list-container {
    --item-radius: 8px;
    height: 640px;
    margin: 0 auto;
}

#list-container .vlist {
    border: none;
    background-color: transparent;
}

/* Horizontal orientation - swap dimensions */
#list-container.vlist--horizontal {
    height: 300px;
    width: 100%;
}

#list-container.vlist--horizontal .vlist-viewport {
    overflow-x: auto;
    overflow-y: hidden;
}

/* ============================================================================
   Grid Info Bar
   ============================================================================ */

.grid-info {
    /* surface via .ui-card .ui-card--compact */
    margin-bottom: 16px;
    font-size: 13px;
    color: var(--text-muted);
}

.grid-info strong {
    color: var(--text);
}

/* ============================================================================
   Photo Card (inside grid items)
   ============================================================================ */

.card {
    position: relative;
    width: 100%;
    height: 100%;
    overflow: hidden;
    border-radius: var(--item-radius);
    background: var(--bg-card);
    cursor: pointer;
}

.card__img {
    width: 100%;
    height: 100%;
    object-fit: cover;
    display: block;
    opacity: 0;
    transition:
        opacity 0.4s ease,
        transform 0.25s ease;
}

.card__img.card__img--loaded {
    opacity: 1;
}

.card:hover .card__img {
    transform: scale(1.05);
}

.card__overlay {
    position: absolute;
    bottom: 0;
    left: 0;
    right: 0;
    padding: 24px 8px 8px;
    background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
    display: flex;
    flex-direction: column;
    gap: 2px;
    opacity: 0;
    transition: opacity 0.2s ease;
}

.card:hover .card__overlay {
    opacity: 1;
}

.card__title {
    font-size: 12px;
    font-weight: var(--fw-bold);
    color: white;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

.card__category {
    font-size: 10px;
    color: rgba(255, 255, 255, 0.7);
    text-transform: uppercase;
    letter-spacing: 0.5px;
}

.card__likes {
    position: absolute;
    top: 6px;
    right: 6px;
    padding: 2px 8px;
    border-radius: 10px;
    background: rgba(0, 0, 0, 0.5);
    color: white;
    font-size: 11px;
    font-weight: var(--fw-bold);
    opacity: 0;
    transition: opacity 0.2s ease;
}

.card:hover .card__likes {
    opacity: 1;
}

/* ============================================================================
   Photo Detail (panel)
   ============================================================================ */

.detail__img {
    width: 100%;
    height: 214.5px;
    border-radius: var(--item-radius, 8px);
    display: block;
    margin-bottom: 8px;
}

.detail__meta {
    display: flex;
    flex-direction: column;
    gap: 2px;
    font-size: 13px;
}

.detail__meta strong {
    font-weight: var(--fw-bold);
}

.detail__meta span {
    font-size: 12px;
    color: var(--text-muted);
}

/* ============================================================================
   vlist grid overrides — remove item borders/padding for photo cards
   ============================================================================ */

#list-container .vlist-item {
    padding: 0;
    border: none;
    background: transparent;
}

#list-container .vlist-item:hover {
    background: transparent;
}

#list-container .vlist-item.vlist-item--selected,
#list-container .vlist-item.vlist-item--focused,
#list-container .vlist-grid-item.vlist-item--focused,
#list-container .vlist-grid-item.vlist-item--selected,
#list-container .vlist-masonry-item.vlist-item--focused,
#list-container .vlist-masonry-item.vlist-item--selected {
    outline: none;
    box-shadow: 0 0 0 2px var(--vlist-focus-ring, #3b82f6);
    border-radius: var(--item-radius);
    background: transparent;
}

/* ============================================================================
   Responsive
   ============================================================================ */

@media (max-width: 820px) {
    #list-container {
        height: 400px;
    }

    #list-container.vlist--horizontal {
        height: 250px;
    }
}