/ Examples
gridmasonryscrollbar
Source
// Photo Album — React variant
// Uses useVList hook from vlist-react with declarative layout config
// Layout mode toggle: Grid ↔ Masonry

import { useState, useCallback, useEffect, useRef } from "react";
import { createRoot } from "react-dom/client";
import { useVList, useVListEvent } from "vlist-react";
import { ITEM_COUNT, ASPECT_RATIO, items, itemTemplate } from "../shared.js";
import { createStats } from "../../stats.js";

// =============================================================================
// Stats (module-level — shared across remounts)
// =============================================================================

let statsInstance: ReturnType<typeof createStats> | null = null;

// =============================================================================
// Grid Container — keyed component that remounts on config change
// =============================================================================

function GridContainer({
  mode,
  orientation,
  columns,
  gap,
  onItem,
}: {
  mode: "grid" | "masonry";
  orientation: "vertical" | "horizontal";
  columns: number;
  gap: number;
  onItem: (item: any) => void;
}) {
  const itemConfig = getItemConfig(mode, orientation, columns, gap);

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

  const { containerRef, instanceRef } = useVList({
    ariaLabel: "Photo gallery",
    orientation,
    ...layoutConfig,
    item: itemConfig,
    items,
    scroll: {
      scrollbar: { autoHide: true },
    },
  });

  // Wire stats
  useEffect(() => {
    if (!statsInstance) {
      statsInstance = createStats({
        getList: () => instanceRef.current,
        getTotal: () => ITEM_COUNT,
        getItemHeight: () => {
          const el = document.getElementById("grid-container");
          if (!el) return 200;
          const innerWidth = el.clientWidth - 2;
          const colW = (innerWidth - (columns - 1) * gap) / columns;
          return mode === "masonry"
            ? Math.round(colW * 1.05)
            : Math.round(colW * ASPECT_RATIO);
        },
        container: "#grid-container",
      });
    }
    statsInstance.update();
  }, []);

  useVListEvent(instanceRef, "scroll", () => {
    statsInstance?.scheduleUpdate();
  });

  useVListEvent(instanceRef, "range:change", ({ range }) => {
    statsInstance?.scheduleUpdate();
  });

  useVListEvent(instanceRef, "velocity:change", ({ velocity }) => {
    statsInstance?.onVelocity(velocity);
  });

  useVListEvent(instanceRef, "item:click", ({ item }) => {
    onItem(item);
  });

  return <div ref={containerRef} id="grid-container" />;
}

// =============================================================================
// Item config helper
// =============================================================================

function getItemConfig(
  mode: string,
  orientation: string,
  columns: number,
  gap: number,
) {
  if (mode === "masonry") {
    return {
      height: (_index: number, ctx: any) =>
        ctx ? Math.round(ctx.columnWidth * items[_index].aspectRatio) : 200,
      width:
        orientation === "horizontal"
          ? (_index: number, ctx: any) =>
              ctx
                ? Math.round(ctx.columnWidth * items[_index].aspectRatio)
                : 200
          : undefined,
      template: itemTemplate,
    };
  }

  // Grid — horizontal needs fixed cross-axis height
  if (orientation === "horizontal") {
    return {
      height: 200, // will be overridden by grid renderer
      width: (_index: number, ctx: any) =>
        ctx ? Math.round(ctx.columnWidth * (4 / 3)) : 200,
      template: itemTemplate,
    };
  }

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

// =============================================================================
// App Component
// =============================================================================

function App() {
  const [mode, setMode] = useState<"grid" | "masonry">("grid");
  const [orientation, setOrientation] = useState<"vertical" | "horizontal">(
    "vertical",
  );
  const [columns, setColumns] = useState(4);
  const [gap, setGap] = useState(8);
  const [selectedPhoto, setSelectedPhoto] = useState<any>(null);

  // Key forces full remount when layout config changes
  const listKey = `${mode}-${orientation}-${columns}-${gap}`;

  // Update footer context
  useEffect(() => {
    const ftMode = document.getElementById("ft-mode");
    const ftOrientation = document.getElementById("ft-orientation");
    if (ftMode) ftMode.textContent = mode;
    if (ftOrientation) ftOrientation.textContent = orientation;
  }, [mode, orientation]);

  const scrollTo = useCallback((target: "first" | "middle" | "last") => {
    const el = document.querySelector("#grid-container .vlist-viewport");
    if (!el) return;
    // Access instance via DOM — the useVList hook manages it internally
    const idx =
      target === "first"
        ? 0
        : target === "middle"
          ? Math.floor(ITEM_COUNT / 2)
          : ITEM_COUNT - 1;
    // Use the instance ref from the child — we need a different approach
    // Dispatch a custom event that the container can listen for
    window.dispatchEvent(
      new CustomEvent("photo-album:scroll-to", { detail: { index: idx } }),
    );
  }, []);

  return (
    <div className="container">
      <header>
        <h1>Photo Album</h1>
        <p className="description">
          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.
        </p>
      </header>

      <div className="split-layout">
        <div className="split-main split-main--full">
          <GridContainer
            key={listKey}
            mode={mode}
            orientation={orientation}
            columns={columns}
            gap={gap}
            onItem={setSelectedPhoto}
          />
        </div>

        <aside className="split-panel">
          {/* Layout */}
          <section className="panel-section">
            <h3 className="panel-title">Layout</h3>

            <div className="panel-row">
              <label className="panel-label">Mode</label>
              <div className="panel-segmented">
                {(["grid", "masonry"] as const).map((m) => (
                  <button
                    key={m}
                    className={`panel-segmented__btn${m === mode ? " panel-segmented__btn--active" : ""}`}
                    onClick={() => setMode(m)}
                  >
                    {m === "grid" ? "Grid" : "Masonry"}
                  </button>
                ))}
              </div>
            </div>

            <div className="panel-row">
              <label className="panel-label">Orientation</label>
              <div className="panel-segmented">
                {(["vertical", "horizontal"] as const).map((o) => (
                  <button
                    key={o}
                    className={`panel-segmented__btn${o === orientation ? " panel-segmented__btn--active" : ""}`}
                    onClick={() => setOrientation(o)}
                  >
                    {o === "vertical" ? "Vertical" : "Horizontal"}
                  </button>
                ))}
              </div>
            </div>

            <div className="panel-row">
              <label className="panel-label">
                {orientation === "horizontal" ? "Rows" : "Columns"}
              </label>
              <div className="panel-btn-group">
                {[3, 4, 5, 6, 10].map((c) => (
                  <button
                    key={c}
                    className={`ctrl-btn${c === columns ? " ctrl-btn--active" : ""}`}
                    onClick={() => setColumns(c)}
                  >
                    {c}
                  </button>
                ))}
              </div>
            </div>

            <div className="panel-row">
              <label className="panel-label">Gap</label>
              <div className="panel-btn-group">
                {[0, 4, 8, 12, 16].map((g) => (
                  <button
                    key={g}
                    className={`ctrl-btn${g === gap ? " ctrl-btn--active" : ""}`}
                    onClick={() => setGap(g)}
                  >
                    {g}
                  </button>
                ))}
              </div>
            </div>
          </section>

          {/* Navigation */}
          <section className="panel-section">
            <h3 className="panel-title">Navigation</h3>
            <div className="panel-row">
              <div className="panel-btn-group">
                <button
                  className="panel-btn panel-btn--icon"
                  title="First"
                  onClick={() => scrollTo("first")}
                >
                  <i className="icon icon--up" />
                </button>
                <button
                  className="panel-btn panel-btn--icon"
                  title="Middle"
                  onClick={() => scrollTo("middle")}
                >
                  <i className="icon icon--center" />
                </button>
                <button
                  className="panel-btn panel-btn--icon"
                  title="Last"
                  onClick={() => scrollTo("last")}
                >
                  <i className="icon icon--down" />
                </button>
              </div>
            </div>
          </section>

          {/* Photo Detail */}
          <section className="panel-section">
            <h3 className="panel-title">Last clicked</h3>
            <div className="panel-detail">
              {selectedPhoto ? (
                <>
                  <img
                    className="detail__img"
                    src={`https://picsum.photos/id/${selectedPhoto.picId}/400/300`}
                    alt={selectedPhoto.title}
                  />
                  <div className="detail__meta">
                    <strong>{selectedPhoto.title}</strong>
                    <span>
                      {selectedPhoto.category} · ♥ {selectedPhoto.likes}
                    </span>
                  </div>
                </>
              ) : (
                <span className="panel-detail__empty">
                  Click a photo to see details
                </span>
              )}
            </div>
          </section>
        </aside>
      </div>

      <footer className="example-footer" id="example-footer">
        <div className="example-footer__left">
          <span className="example-footer__stat">
            <strong id="ft-progress">0%</strong>
          </span>
          <span className="example-footer__stat">
            <span id="ft-velocity">0.00</span> /{" "}
            <strong id="ft-velocity-avg">0.00</strong>
            <span className="example-footer__unit">px/ms</span>
          </span>
          <span className="example-footer__stat">
            <span id="ft-dom">0</span> / <strong id="ft-total">0</strong>
            <span className="example-footer__unit">items</span>
          </span>
        </div>
        <div className="example-footer__right">
          <span className="example-footer__stat">
            <strong id="ft-mode">grid</strong>
          </span>
          <span className="example-footer__stat">
            <strong id="ft-orientation">vertical</strong>
          </span>
        </div>
      </footer>
    </div>
  );
}

// =============================================================================
// Mount
// =============================================================================

createRoot(document.getElementById("react-root")!).render(<App />);
<div id="react-root"></div>
// 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;
    }
}