/ Examples
autosizeinvert

Variable Sizes

Social feed where item sizes are unknown upfront. Switch between Mode A (pre-measure all items at init via hidden DOM element) and Mode B (estimated size, let ResizeObserver measure on the fly and correct scroll position).

Posts

0% 0.00 / 0.00 px/ms 0 / 0 items
Mode B 200px estimate
Source
// Variable Sizes — Social feed with Mode A / Mode B size handling
// Demonstrates both approaches to variable-height items:
//   A · Pre-measure all items via hidden DOM element (size function)
//   B · Auto-size via estimatedHeight + ResizeObserver
// Uses split-layout pattern with side panel, mode toggle, and info bar stats.

import { vlist, withAutoSize /* withScrollbar */ } from "vlist";
import { createStats } from "../stats.js";
import { createInfoUpdater } from "../info.js";
import { initModeToggle } from "./controls.js";
import { getAllPosts } from "../../src/api/posts.js";

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

const TOTAL_POSTS = 5000;
const ESTIMATED_POST_HEIGHT = 240;
const VLIST_PADDING = 12; // must match padding: passed to vlist()

// =============================================================================
// Data — generated from API module (deterministic, same every time)
// =============================================================================

export const items = getAllPosts(TOTAL_POSTS);
export let list = null;
export let currentMode = "a"; // "a" | "b"

export function setCurrentMode(v) {
  currentMode = v;
}

// =============================================================================
// Templates
// =============================================================================

const renderPostHTML = (item) => `
  <article class="ui-card ui-card--lg post-card">
    <div class="post-card__header">
      <img class="post-card__avatar" src="${item.avatarUrl}" alt="${item.user}" loading="lazy" />
      <div class="post-card__meta">
        <span class="post-card__user">${item.user}</span>
        <span class="post-card__time">${item.time}</span>
      </div>
    </div>
    <div class="post-card__title">${item.title}</div>
    <div class="post-card__body">${item.body}</div>
    <div class="post-card__actions">
      <span class="post-card__action"><span class="post-card__action-icon">❤️</span> ${item.likes}</span>
      <span class="post-card__action"><span class="post-card__action-icon">💬</span> ${item.comments}</span>
      <span class="post-card__action"><span class="post-card__action-icon">🔄</span> ${item.shares}</span>
    </div>
  </article>
`;

const renderItem = (item) => renderPostHTML(item);

// =============================================================================
// Mode A — Pre-measure all items via hidden DOM element
// =============================================================================

/**
 * Measure the actual rendered height of every item by inserting its HTML
 * into a hidden element that matches the list's inner width.
 *
 * We cache by body text so items with identical content share a single
 * measurement. For 5 000 items with ~12 unique body texts this means
 * ~12 actual DOM measurements instead of 5 000.
 */
const measureSizes = (itemList, container, vlistPadding = 0) => {
  // Items render inside the vlist content div which applies vlistPadding on all
  // sides (border-box). The cross-axis (left + right) padding narrows each item,
  // so the measurer must use that exact inner width — otherwise text wraps at a
  // different point and measured heights diverge from actual rendered heights.
  const innerWidth = container.offsetWidth - vlistPadding * 2;
  const measurer = document.createElement("div");
  measurer.style.cssText =
    "position:absolute;top:0;left:0;visibility:hidden;pointer-events:none;" +
    `width:${innerWidth}px;box-sizing:border-box;`;
  document.body.appendChild(measurer);

  const cache = new Map();
  let uniqueCount = 0;

  for (const item of itemList) {
    const key = item.body;
    if (cache.has(key)) {
      item.size = cache.get(key);
      continue;
    }

    measurer.innerHTML = renderPostHTML(item);
    const measured = measurer.firstElementChild.offsetHeight;
    item.size = measured;
    cache.set(key, measured);
    uniqueCount++;
  }

  measurer.remove();
  return uniqueCount;
};

// =============================================================================
// DOM references
// =============================================================================

const containerEl = document.getElementById("list-container");

// Measurement info
const infoStrategyEl = document.getElementById("info-strategy");
const infoInitEl = document.getElementById("info-init");
const infoUniqueEl = document.getElementById("info-unique");

// Info bar right side
const infoModeEl = document.getElementById("info-mode");
const infoEstimateEl = document.getElementById("info-estimate");

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

export const stats = createStats({
  getScrollPosition: () => list?.getScrollPosition() ?? 0,
  getTotal: () => items.length,
  getItemSize: () => ESTIMATED_POST_HEIGHT,
  getContainerSize: () =>
    document.querySelector("#list-container")?.clientHeight ?? 0,
});

const updateInfo = createInfoUpdater(stats);

// =============================================================================
// Create / Recreate list — called when mode changes
// =============================================================================

let firstVisibleIndex = 0;

export function createList() {
  if (list) {
    list.destroy();
    list = null;
  }
  containerEl.innerHTML = "";

  let initTime = 0;
  let uniqueSizes = 0;

  if (currentMode === "a") {
    // Mode A: pre-measure all items, then use size function
    const start = performance.now();
    if (items.length > 0) {
      uniqueSizes = measureSizes(items, containerEl, VLIST_PADDING);
    }
    initTime = performance.now() - start;

    list = vlist({
      container: containerEl,
      ariaLabel: "Social feed",
      items,
      padding: VLIST_PADDING,

      item: {
        height: (index) => items[index]?.size ?? ESTIMATED_POST_HEIGHT,
        gap: 12,
        template: renderItem,
      },
    })
      // .use(withScrollbar())
      .build();
  } else {
    // Mode B: estimated size, auto-measured by ResizeObserver
    const start = performance.now();

    list = vlist({
      container: containerEl,
      ariaLabel: "Social feed",
      padding: VLIST_PADDING,
      items,
      item: {
        estimatedHeight: ESTIMATED_POST_HEIGHT,
        gap: 12,

        template: renderItem,
      },
    })
      .use(withAutoSize())
      // .use(withScrollbar())
      .build();

    initTime = performance.now() - start;
  }

  // Wire stats events
  list.on("scroll", updateInfo);
  list.on("range:change", ({ range }) => {
    firstVisibleIndex = range.start;
    updateInfo();
  });
  list.on("velocity:change", ({ velocity }) => {
    stats.onVelocity(velocity);
    updateInfo();
  });

  // Restore scroll position
  if (firstVisibleIndex > 0) {
    list.scrollToIndex(firstVisibleIndex, "start");
  }

  updateInfo();
  updatePanelInfo(initTime, uniqueSizes);
}

// =============================================================================
// Panel info — measurement section + footer right
// =============================================================================

function updatePanelInfo(initTime, uniqueSizes) {
  const modeLabel = currentMode === "a" ? "Mode A" : "Mode B";

  // Toggle mode description visibility
  const descA = document.getElementById("mode-desc-a");
  const descB = document.getElementById("mode-desc-b");
  if (descA) descA.style.display = currentMode === "a" ? "" : "none";
  if (descB) descB.style.display = currentMode === "b" ? "" : "none";

  if (infoModeEl) infoModeEl.textContent = modeLabel;
  if (infoEstimateEl) {
    infoEstimateEl.textContent =
      currentMode === "a" ? "pre-measured" : `${ESTIMATED_POST_HEIGHT}px`;
  }

  if (infoStrategyEl) {
    infoStrategyEl.textContent =
      currentMode === "a" ? "height: (i) => px" : "estimatedHeight";
  }
  if (infoInitEl) {
    infoInitEl.textContent = `${initTime.toFixed(0)}ms`;
  }
  if (infoUniqueEl) {
    infoUniqueEl.textContent = currentMode === "a" ? String(uniqueSizes) : "–";
  }
}

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

initModeToggle();
createList();
/* Variable Sizes — example-specific styles only
   Common styles (.container, h1, .description, .stats, footer)
   are provided by examples/examples.css using shell.css design tokens.
   UI components (.split-layout, .split-panel, .ui-*)
   is also provided by examples/examples.css. */

/* ============================================================================
   List container
   ============================================================================ */

#list-container {
    height: 600px;
    background: var(--vlist-bg);
    border-radius: 2px;
}

/* ============================================================================
   vlist item overrides
   ============================================================================ */

#list-container .vlist {
    background-color: #4e6070;
}

#list-container .vlist-item {
    display: flex;
    background-color: transparent;
}

/* ============================================================================
   Post Card — social feed card with avatar, title, body, actions
   ============================================================================ */

.post-card {
    display: flex;
    flex-direction: column;
    gap: 8px;
    width: 100%;
    box-sizing: border-box;
}

/* --- Header: avatar + name/time --- */

.post-card__header {
    display: flex;
    align-items: center;
    gap: 10px;
}

.post-card__avatar {
    width: 40px;
    height: 40px;
    border-radius: 50%;
    object-fit: cover;
    flex-shrink: 0;
    background: var(--vlist-border);
}

.post-card__meta {
    display: flex;
    flex-direction: column;
    gap: 1px;
    min-width: 0;
}

.post-card__user {
    font-weight: 600;
    font-size: 15px;
    color: var(--vlist-text);
    line-height: 1.3;
}

.post-card__time {
    font-size: 12px;
    color: var(--vlist-text-muted);
    line-height: 1.3;
}

/* --- Title --- */

.post-card__title {
    font-size: 16px;
    font-weight: 700;
    color: var(--vlist-text);
    line-height: 1.35;
    padding-top: 2px;
}

/* --- Body text --- */

.post-card__body {
    font-size: 14px;
    color: var(--vlist-text-muted);
    line-height: 1.55;
    word-break: break-word;
}

/* --- Action bar --- */

.post-card__actions {
    display: flex;
    align-items: center;
    gap: 16px;
    padding-top: 4px;
    border-top: 1px solid var(--vlist-text-dim);
}

.post-card__action {
    display: inline-flex;
    align-items: center;
    gap: 4px;
    font-size: 13px;
    font-weight: 500;
    color: var(--text-dim);
    cursor: default;
    user-select: none;
}

.post-card__action-icon {
    font-size: 14px;
    line-height: 1;
}

/* ============================================================================
   Mode description
   ============================================================================ */

.ui-desc {
    font-size: 12.5px;
    line-height: 1.55;
    color: var(--vlist-text-muted);
    margin: 0;
}

.ui-desc code {
    font-size: 11.5px;
    padding: 1px 4px;
    border-radius: 3px;
    background: rgba(255, 255, 255, 0.08);
}
<div class="container">
    <header>
        <h1>Variable Sizes</h1>
        <p class="description">
            Social feed where item sizes are <strong>unknown upfront</strong>.
            Switch between <strong>Mode A</strong> (pre-measure all items at
            init via hidden DOM element) and <strong>Mode B</strong> (estimated
            size, let <code>ResizeObserver</code> measure on the fly and correct
            scroll position).
        </p>
    </header>

    <div class="split-layout">
        <div class="split-main">
            <h2 class="sr-only">Posts</h2>
            <div id="list-container"></div>
        </div>

        <aside class="split-panel">
            <!-- Mode -->
            <section class="ui-section">
                <h3 class="ui-title">Mode</h3>
                <div class="ui-row">
                    <div class="ui-segmented" id="mode-toggle">
                        <button class="ui-segmented__btn" data-mode="a">
                            A · Pre-measure
                        </button>
                        <button class="ui-segmented__btn" data-mode="b">
                            B · Auto-size
                        </button>
                    </div>
                </div>
            </section>

            <!-- Measurement -->
            <section class="ui-section" id="section-measurement">
                <h3 class="ui-title">Measurement</h3>
                <div class="ui-row">
                    <span class="ui-label">Strategy</span>
                    <span class="ui-value" id="info-strategy"
                        >estimatedHeight</span
                    >
                </div>
                <div class="ui-row">
                    <span class="ui-label">Init time</span>
                    <span class="ui-value" id="info-init">–</span>
                </div>
                <div class="ui-row">
                    <span class="ui-label">Unique sizes</span>
                    <span class="ui-value" id="info-unique">–</span>
                </div>
            </section>

            <!-- Navigation -->
            <section class="ui-section">
                <h3 class="ui-title">Navigation</h3>
                <div class="ui-row">
                    <div class="ui-btn-group">
                        <button
                            id="jump-top"
                            class="ui-btn ui-btn--icon"
                            title="Top"
                        >
                            <i class="icon icon--up"></i>
                        </button>
                        <button
                            id="jump-middle"
                            class="ui-btn ui-btn--icon"
                            title="Middle"
                        >
                            <i class="icon icon--center"></i>
                        </button>
                        <button
                            id="jump-bottom"
                            class="ui-btn ui-btn--icon"
                            title="Bottom"
                        >
                            <i class="icon icon--down"></i>
                        </button>
                        <button
                            id="jump-random"
                            class="ui-btn ui-btn--icon"
                            title="Random"
                        >
                            <i class="icon icon--shuffle"></i>
                        </button>
                    </div>
                </div>
            </section>

            <!-- Mode description -->
            <section class="ui-section ui-section--desc" id="section-mode-desc">
                <h3 class="ui-title">How it works</h3>
                <p class="ui-desc" id="mode-desc-a">
                    All items are rendered into a hidden DOM element at init to
                    measure their exact heights. The list then uses a
                    <code>height(index)</code> function for pixel-perfect
                    positioning. Best when you have few unique sizes or can
                    cache measurements across sessions — accurate scrollbar and
                    instant jump-to-index at the cost of a slower init.
                </p>
                <p class="ui-desc" id="mode-desc-b">
                    Items start with an <code>estimatedHeight</code>. As they
                    enter the viewport, a <code>ResizeObserver</code> measures
                    their actual size and corrects the scroll position on the
                    fly. Best for large datasets or dynamic content —
                    near-instant init and no upfront DOM work, at the cost of an
                    approximate scrollbar until items are visited.
                </p>
            </section>
        </aside>
    </div>

    <div class="example-info" id="example-info">
        <div class="example-info__left">
            <span class="example-info__stat">
                <strong id="info-progress">0%</strong>
            </span>
            <span class="example-info__stat">
                <span id="info-velocity">0.00</span> /
                <strong id="info-velocity-avg">0.00</strong>
                <span class="example-info__unit">px/ms</span>
            </span>
            <span class="example-info__stat">
                <span id="info-dom">0</span> /
                <strong id="info-total">0</strong>
                <span class="example-info__unit">items</span>
            </span>
        </div>
        <div class="example-info__right">
            <span class="example-info__stat">
                <strong id="info-mode">Mode B</strong>
            </span>
            <span class="example-info__stat">
                <strong id="info-estimate">200px</strong>
                <span class="example-info__unit">estimate</span>
            </span>
        </div>
    </div>
</div>