/ Examples
gridmasonryscrollbar

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.

Source
// Photo Album — Svelte variant
// Uses vlist-svelte action with declarative layout config
// Layout mode toggle: Grid ↔ Masonry

import { vlist, onVListEvent } from "vlist-svelte";
import { createStats } from "../../stats.js";
import {
  ITEM_COUNT,
  ASPECT_RATIO,
  items,
  itemTemplate,
  currentMode,
  currentOrientation,
  currentColumns,
  currentGap,
  list,
  setList,
  setCreateView,
} from "../shared.js";
import "../controls.js";

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

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

const stats = createStats({
  getList: () => list,
  getTotal: () => ITEM_COUNT,
  getItemHeight: () => getEffectiveItemHeight(),
  container: "#grid-container",
});

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

let action = null;
let firstVisibleIndex = 0;

function getItemConfig(mode, orientation) {
  if (mode === "masonry") {
    return {
      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,
    };
  }

  // Grid mode
  if (orientation === "horizontal") {
    const container = document.getElementById("grid-container");
    const innerHeight = container.clientHeight - 2;
    const colWidth =
      (innerHeight - (currentColumns - 1) * currentGap) / currentColumns;

    return {
      height: Math.round(colWidth),
      width: (_index, ctx) =>
        ctx ? Math.round(ctx.columnWidth * (4 / 3)) : 200,
      template: itemTemplate,
    };
  }

  return {
    height: (_index, ctx) =>
      ctx ? Math.round(ctx.columnWidth * ASPECT_RATIO) : 200,
    template: itemTemplate,
  };
}

function createView() {
  // Destroy previous action
  if (action && action.destroy) {
    action.destroy();
    action = null;
    setList(null);
  }

  const container = document.getElementById("grid-container");
  container.innerHTML = "";

  const mode = currentMode;
  const orientation = currentOrientation;
  const columns = currentColumns;
  const gap = currentGap;

  const layoutConfig =
    mode === "masonry"
      ? { layout: "masonry", masonry: { columns, gap } }
      : { layout: "grid", grid: { columns, gap } };

  // Create vlist via Svelte action with declarative config
  action = vlist(container, {
    config: {
      ariaLabel: "Photo gallery",
      orientation,
      ...layoutConfig,
      item: getItemConfig(mode, orientation),
      items,
      scroll: {
        scrollbar: { autoHide: true },
      },
    },
    onInstance: (inst) => {
      setList(inst);

      // Wire events
      onVListEvent(inst, "scroll", () => stats.scheduleUpdate());
      onVListEvent(inst, "range:change", ({ range }) => {
        firstVisibleIndex =
          mode === "grid" ? range.start * columns : range.start;
        stats.scheduleUpdate();
      });
      onVListEvent(inst, "velocity:change", ({ velocity }) =>
        stats.onVelocity(velocity),
      );
      onVListEvent(inst, "item:click", ({ item }) => {
        showDetail(item);
      });

      // Restore scroll position to first visible item
      if (firstVisibleIndex > 0) {
        inst.scrollToIndex(firstVisibleIndex, "start");
      }

      stats.update();
      updateContext();
    },
  });
}

// Register createView so controls.js can call it
setCreateView(createView);

// =============================================================================
// 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>
  `;
}

// =============================================================================
// Footer — right side (contextual)
// =============================================================================

const ftMode = document.getElementById("ft-mode");
const ftOrientation = document.getElementById("ft-orientation");

function updateContext() {
  ftMode.textContent = currentMode;
  ftOrientation.textContent = currentOrientation;
}

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

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

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

export const PHOTO_COUNT = 1084;
export const ITEM_COUNT = 600;
export const 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];

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

export const items = Array.from({ length: ITEM_COUNT }, (_, i) => {
  const picId = i % PHOTO_COUNT;
  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"
    />
    <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 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 setList(v) {
  list = v;
}

// =============================================================================
// View lifecycle — set by each variant's script.js
// =============================================================================

let _createView = () => {};

export function createView() {
  _createView();
}

export function setCreateView(fn) {
  _createView = fn;
}
/* 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.
   Panel system (.split-layout, .split-panel, .panel-*)
   is also provided by example/example.css. */

/* Grid container */
#grid-container {
    height: 600px;
    margin: 0 auto;
}

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

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

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

.grid-info {
    padding: 8px 14px;
    margin-bottom: 16px;
    border-radius: 8px;
    border: 1px solid var(--border);
    background: var(--bg-card);
    font-size: 13px;
    color: var(--text-muted);
}

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

/* ============================================================================
   Control Buttons (columns / gap)
   ============================================================================ */

.ctrl-btn {
    padding: 6px 14px;
    border: 1px solid var(--border);
    border-radius: 6px;
    background: var(--bg-card);
    color: var(--text-muted);
    font-size: 13px;
    font-weight: 600;
    font-family: inherit;
    cursor: pointer;
    transition: all 0.15s ease;
    min-width: 36px;
    text-align: center;
}

.ctrl-btn:hover {
    border-color: var(--accent);
    color: var(--accent-text);
}

.ctrl-btn--active {
    background: var(--accent);
    color: white;
    border-color: var(--accent);
}

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

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

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

.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: 600;
    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: 600;
    opacity: 0;
    transition: opacity 0.2s ease;
}

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

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

.detail__img {
    width: 100%;
    border-radius: 8px;
    display: block;
    margin-bottom: 8px;
}

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

.detail__meta strong {
    font-weight: 600;
}

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

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

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

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

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

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

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