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>