/ Examples
coreinvert

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).

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 footer stats.

import { vlist } from "vlist";
import { createStats } from "../stats.js";
import { initModeToggle } from "./controls.js";
import { getAllPosts } from "../../src/api/posts.js";

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

const TOTAL_POSTS = 5000;
const ESTIMATED_POST_HEIGHT = 200;

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

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

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

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

const renderPostHTML = (item) => `
  <article class="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) => {
  const measurer = document.createElement("div");
  measurer.style.cssText =
    "position:absolute;top:0;left:0;visibility:hidden;pointer-events:none;" +
    `width:${container.offsetWidth}px;`;
  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");

// Footer right side
const ftModeEl = document.getElementById("ft-mode");
const ftEstimateEl = document.getElementById("ft-estimate");

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

export const stats = createStats({
  getList: () => list,
  getTotal: () => items.length,
  getItemHeight: () => ESTIMATED_POST_HEIGHT,
  container: "#list-container",
});

// =============================================================================
// 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);
    }
    initTime = performance.now() - start;

    list = vlist({
      container: containerEl,
      ariaLabel: "Social feed",
      items,
      item: {
        height: (index) => items[index]?.size ?? ESTIMATED_POST_HEIGHT,
        template: renderItem,
      },
    }).build();
  } else {
    // Mode B: estimated size, auto-measured by ResizeObserver
    const start = performance.now();

    list = vlist({
      container: containerEl,
      ariaLabel: "Social feed",
      items,
      item: {
        estimatedHeight: ESTIMATED_POST_HEIGHT,
        template: renderItem,
      },
    }).build();

    initTime = performance.now() - start;
  }

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

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

  stats.update();
  updatePanelInfo(initTime, uniqueSizes);
}

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

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

  if (ftModeEl) ftModeEl.textContent = modeLabel;
  if (ftEstimateEl) {
    ftEstimateEl.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 {
    padding: 0;
    display: flex;
    margin: 10px 20px;
    background-color: transparent;
}

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

.post-card {
    display: flex;
    flex-direction: column;
    gap: 8px;
    padding: 16px 20px;
    background: #ffffff;
    border-radius: 12px;
    width: 100%;
    height: calc(100% - 12px);
    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;
}
<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">
            <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 ui-segmented__btn--active"
                            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>
        </aside>
    </div>

    <footer class="example-footer" id="example-footer">
        <div class="example-footer__left">
            <span class="example-footer__stat">
                <strong id="ft-progress">0%</strong>
            </span>
            <span class="example-footer__stat">
                <span id="ft-velocity">0.00</span> /
                <strong id="ft-velocity-avg">0.00</strong>
                <span class="example-footer__unit">px/ms</span>
            </span>
            <span class="example-footer__stat">
                <span id="ft-dom">0</span> /
                <strong id="ft-total">0</strong>
                <span class="example-footer__unit">items</span>
            </span>
        </div>
        <div class="example-footer__right">
            <span class="example-footer__stat">
                <strong id="ft-mode">Mode B</strong>
            </span>
            <span class="example-footer__stat">
                <strong id="ft-estimate">200px</strong>
                <span class="example-footer__unit">estimate</span>
            </span>
        </div>
    </footer>
</div>