scalescrollbar
Large List
Builder pattern with vlist/builder +
withScale + withScrollbar plugins. Handles
100K–5M items with automatic scroll scaling when total height
exceeds the browser's 16.7M pixel limit.
Items
0%
0.00 /
0.00
px/ms
0 /
0
items
0%
virtualized
NATIVE
1.0×
Source
// Builder Million Items — Composable entry point
// Uses vlist/builder with withScale + withScrollbar plugins
// Demonstrates handling 1M+ items with automatic scroll scaling
import { vlist, withScale, withScrollbar } from "vlist";
import { createStats } from "../../stats.js";
import { createInfoUpdater } from "../../info.js";
// =============================================================================
// Constants
// =============================================================================
const ITEM_HEIGHT = 48;
const SIZES = {
"100k": 100_000,
"500k": 500_000,
"1m": 1_000_000,
"2m": 2_000_000,
"5m": 5_000_000,
};
const COLORS = [
"#667eea",
"#764ba2",
"#f093fb",
"#f5576c",
"#4facfe",
"#43e97b",
"#fa709a",
"#fee140",
];
// Simple hash for consistent per-item values
const hash = (n) => {
let h = (n + 1) * 2654435761;
h ^= h >>> 16;
return Math.abs(h);
};
// =============================================================================
// Generate lightweight items (only id — display data computed in template)
// =============================================================================
const generateItems = (count) =>
Array.from({ length: count }, (_, i) => ({ id: i + 1 }));
// =============================================================================
// Item template
// =============================================================================
const itemTemplate = (_item, index) => {
const h = hash(index);
const value = h % 100;
const hex = h.toString(16).slice(0, 8).toUpperCase();
const color = COLORS[index % COLORS.length];
return `
<div class="item-row">
<div class="item-color" style="background:${color}"></div>
<div class="item-info">
<span class="item-label">#${(index + 1).toLocaleString()}</span>
<span class="item-hash">${hex}</span>
</div>
<div class="item-bar-wrap">
<div class="item-bar" style="width:${value}%;background:${color}"></div>
</div>
<span class="item-value">${value}%</span>
</div>
`;
};
// =============================================================================
// DOM references
// =============================================================================
const scrollPosEl = document.getElementById("scroll-position");
const scrollDirEl = document.getElementById("scroll-direction");
const rangeEl = document.getElementById("visible-range");
const sizeButtons = document.getElementById("size-buttons");
// Info bar right-side elements
const infoVirtualizedEl = document.getElementById("info-virtualized");
const infoScaleEl = document.getElementById("info-scale");
const infoModeEl = document.getElementById("info-mode");
const infoModeStatEl = document.getElementById("info-mode-stat");
// =============================================================================
// Shared info bar stats (left side — progress, velocity, items)
// =============================================================================
const stats = createStats({
getScrollPosition: () => list?.getScrollPosition() ?? 0,
getTotal: () => SIZES[currentSize],
getItemSize: () => ITEM_HEIGHT,
getContainerSize: () =>
document.querySelector("#list-container")?.clientHeight ?? 0,
});
const updateInfo = createInfoUpdater(stats);
// =============================================================================
// State
// =============================================================================
let currentSize = "1m";
let list = null;
// =============================================================================
// Create / Recreate list
// =============================================================================
function createList(sizeKey) {
// Destroy previous
if (list) {
list.destroy();
list = null;
}
// Clear container
const container = document.getElementById("list-container");
container.innerHTML = "";
const count = SIZES[sizeKey];
const items = generateItems(count);
const builder = vlist({
container: "#list-container",
ariaLabel: `${count.toLocaleString()} items list`,
item: {
height: ITEM_HEIGHT,
template: itemTemplate,
},
items,
});
if (count > 100_000) {
builder.use(withScale()).use(withScrollbar({ autoHide: true }));
}
list = builder.build();
// Bind events
list.on("scroll", ({ scrollPosition, direction }) => {
scrollPosEl.textContent = `${Math.round(scrollPosition).toLocaleString()}px`;
scrollDirEl.textContent = direction === "up" ? "↑ up" : "↓ down";
updateInfo();
});
list.on("range:change", ({ range }) => {
rangeEl.textContent = `${range.start.toLocaleString()} – ${range.end.toLocaleString()}`;
updateInfo();
});
list.on("velocity:change", ({ velocity }) => {
stats.onVelocity(velocity);
updateInfo();
});
// Update info bar
updateInfo();
updateContext(count);
}
// =============================================================================
// Info bar right side — context (virtualized %, scale mode)
// =============================================================================
function updateContext(count) {
const totalHeight = count * ITEM_HEIGHT;
const maxHeight = 16_777_216; // browser limit ~16.7M px
const isScaled = totalHeight > maxHeight;
const ratio = isScaled ? (totalHeight / maxHeight).toFixed(1) : "1.0";
const domNodes = document.querySelectorAll(".vlist-item").length;
const virtualized = ((1 - domNodes / count) * 100).toFixed(2);
infoVirtualizedEl.textContent = `${virtualized}%`;
infoScaleEl.textContent = `${ratio}×`;
infoModeEl.textContent = isScaled ? "SCALED" : "NATIVE";
infoModeStatEl.className = `example-info__stat ${isScaled ? "example-info__stat--warn" : "example-info__stat--ok"}`;
}
// =============================================================================
// Size selector buttons
// =============================================================================
sizeButtons.addEventListener("click", (e) => {
const btn = e.target.closest("[data-size]");
if (!btn) return;
const size = btn.dataset.size;
if (size === currentSize) return;
currentSize = size;
// Update active state
sizeButtons.querySelectorAll("button").forEach((b) => {
b.classList.toggle("ui-segmented__btn--active", b.dataset.size === size);
});
createList(size);
});
// =============================================================================
// Navigation controls
// =============================================================================
const smoothToggle = document.getElementById("smooth-toggle");
/** Build scrollToIndex options respecting the smooth toggle */
const scrollOpts = (align) =>
smoothToggle.checked ? { align, behavior: "smooth", duration: 800 } : align;
document.getElementById("btn-first").addEventListener("click", () => {
list.scrollToIndex(0, scrollOpts("start"));
});
document.getElementById("btn-middle").addEventListener("click", () => {
list.scrollToIndex(Math.floor(SIZES[currentSize] / 2), scrollOpts("center"));
});
document.getElementById("btn-last").addEventListener("click", () => {
list.scrollToIndex(SIZES[currentSize] - 1, scrollOpts("end"));
});
document.getElementById("btn-random").addEventListener("click", () => {
const idx = Math.floor(Math.random() * SIZES[currentSize]);
list.scrollToIndex(idx, scrollOpts("center"));
document.getElementById("scroll-index").value = idx;
});
document.getElementById("btn-go").addEventListener("click", () => {
const idx = parseInt(document.getElementById("scroll-index").value, 10);
if (Number.isNaN(idx)) return;
const align = document.getElementById("scroll-align").value;
list.scrollToIndex(
Math.max(0, Math.min(idx, SIZES[currentSize] - 1)),
scrollOpts(align),
);
});
document.getElementById("scroll-index").addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
document.getElementById("btn-go").click();
}
});
// =============================================================================
// Initialise with 1M items
// =============================================================================
createList(currentSize);
<div class="container">
<header>
<h1>Large List</h1>
<p class="description">
Builder pattern with <code>vlist/builder</code> +
<code>withScale</code> + <code>withScrollbar</code> plugins. Handles
100K–5M items with automatic scroll scaling when total height
exceeds the browser's 16.7M pixel limit.
</p>
</header>
<div class="split-layout">
<div class="split-main">
<h2 class="sr-only">Items</h2>
<div id="list-container"></div>
</div>
<aside class="split-panel">
<!-- Size -->
<section class="ui-section">
<h3 class="ui-title">Size</h3>
<div class="ui-row">
<div class="ui-segmented" id="size-buttons">
<button class="ui-segmented__btn" data-size="100k">
100K
</button>
<button class="ui-segmented__btn" data-size="500k">
500K
</button>
<button
class="ui-segmented__btn ui-segmented__btn--active"
data-size="1m"
>
1M
</button>
<button class="ui-segmented__btn" data-size="2m">
2M
</button>
<button class="ui-segmented__btn" data-size="5m">
5M
</button>
</div>
</div>
</section>
<!-- Navigation -->
<section class="ui-section">
<h3 class="ui-title">Navigation</h3>
<div class="ui-row">
<label class="ui-label" for="scroll-index"
>Scroll to index</label
>
</div>
<div class="ui-row">
<div class="ui-input-group">
<input
type="number"
id="scroll-index"
min="0"
value="0"
class="ui-input"
/>
<select id="scroll-align" class="ui-select">
<option value="start">start</option>
<option value="center">center</option>
<option value="end">end</option>
</select>
<button
id="btn-go"
class="ui-btn ui-btn--icon"
title="Go"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="currentColor"
>
<path
d="M12 4l-1.41 1.41L16.17 11H4v2h12.17l-5.58 5.59L12 20l8-8z"
/>
</svg>
</button>
</div>
</div>
<div class="ui-row">
<div class="ui-btn-group">
<button
id="btn-first"
class="ui-btn ui-btn--icon"
title="First item"
>
<i class="icon icon--up"></i>
</button>
<button
id="btn-middle"
class="ui-btn ui-btn--icon"
title="Middle"
>
<i class="icon icon--center"></i>
</button>
<button
id="btn-last"
class="ui-btn ui-btn--icon"
title="Last item"
>
<i class="icon icon--down"></i>
</button>
<button
id="btn-random"
class="ui-btn ui-btn--icon"
title="Random item"
>
<i class="icon icon--shuffle"></i>
</button>
</div>
</div>
<div class="ui-row">
<label class="ui-label">Smooth scroll</label>
<label class="ui-switch">
<input type="checkbox" id="smooth-toggle" checked />
<span class="ui-switch__track"></span>
</label>
</div>
</section>
<!-- Viewport -->
<section class="ui-section">
<h3 class="ui-title">Viewport</h3>
<div class="ui-row">
<span class="ui-label">Scroll</span>
<span class="ui-value" id="scroll-position">0px</span>
</div>
<div class="ui-row">
<span class="ui-label">Direction</span>
<span class="ui-value" id="scroll-direction">–</span>
</div>
<div class="ui-row">
<span class="ui-label">Range</span>
<span class="ui-value" id="visible-range">–</span>
</div>
</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-virtualized">0%</strong>
<span class="example-info__unit">virtualized</span>
</span>
<span class="example-info__stat" id="info-mode-stat">
<strong id="info-mode">NATIVE</strong>
<span class="example-info__unit" id="info-scale">1.0×</span>
</span>
</div>
</div>
</div>
// Shared data and utilities for large-list example variants
// This file is imported by all framework implementations to avoid duplication
// =============================================================================
// Constants
// =============================================================================
export const ITEM_HEIGHT = 48;
export const SIZES = {
"100k": 100_000,
"500k": 500_000,
"1m": 1_000_000,
"2m": 2_000_000,
"5m": 5_000_000,
};
export const COLORS = [
"#667eea",
"#764ba2",
"#f093fb",
"#f5576c",
"#4facfe",
"#43e97b",
"#fa709a",
"#fee140",
];
// =============================================================================
// Utilities
// =============================================================================
// Simple hash for consistent per-item values
export function hash(n) {
let h = (n + 1) * 2654435761;
h ^= h >>> 16;
return Math.abs(h);
}
// Generate items on the fly
export function generateItems(count) {
return Array.from({ length: count }, (_, i) => ({
id: i + 1,
value: hash(i) % 100,
hash: hash(i).toString(16).slice(0, 8).toUpperCase(),
color: COLORS[i % COLORS.length],
}));
}
// =============================================================================
// Templates
// =============================================================================
// Item template
export const itemTemplate = (item, index) => `
<div class="item-row">
<div class="item-color" style="background:${item.color}"></div>
<div class="item-info">
<span class="item-label">#${(index + 1).toLocaleString()}</span>
<span class="item-hash">${item.hash}</span>
</div>
<div class="item-bar-wrap">
<div class="item-bar" style="width:${item.value}%;background:${item.color}"></div>
</div>
<span class="item-value">${item.value}%</span>
</div>
`;
// =============================================================================
// Compression Info
// =============================================================================
export function getCompressionInfo(count, itemHeight = ITEM_HEIGHT) {
const totalHeight = count * itemHeight;
const maxHeight = 16_777_216; // browser limit ~16.7M px
const isCompressed = totalHeight > maxHeight;
const ratio = isCompressed ? (totalHeight / maxHeight).toFixed(1) : "1.0";
return {
isCompressed,
virtualHeight: totalHeight,
ratio,
};
}
// Format virtualization percentage
export function calculateVirtualization(domNodes, total) {
if (total > 0 && domNodes > 0) {
return ((1 - domNodes / total) * 100).toFixed(4);
}
return "0.0000";
}
/* Builder Million Items — example-specific styles only
Common styles (.container, h1, .description, .stats, footer)
are provided by example/example.css using shell.css design tokens.
UI components (.split-layout, .split-panel, .ui-*)
is also provided by example/example.css. */
/* List container */
#list-container {
height: 600px;
margin: 0 auto;
}
/* ============================================================================
Footer stat modifiers
============================================================================ */
.example-info__stat--ok strong {
color: #51cf66;
}
.example-info__stat--warn strong {
color: #ff6b6b;
}
/* ============================================================================
Item styles (inside list)
============================================================================ */
.item-row {
display: flex;
align-items: center;
gap: 12px;
padding: 0 16px;
height: 100%;
}
.item-color {
width: 8px;
height: 28px;
border-radius: 4px;
flex-shrink: 0;
}
.item-info {
display: flex;
flex-direction: column;
min-width: 80px;
flex-shrink: 0;
}
.item-label {
font-weight: 600;
font-size: 13px;
white-space: nowrap;
}
.item-hash {
font-size: 11px;
font-family: var(--font-mono);
color: var(--text-muted);
}
.item-bar-wrap {
flex: 1;
height: 6px;
background: var(--border);
border-radius: 3px;
overflow: hidden;
min-width: 0;
}
.item-bar {
height: 100%;
border-radius: 3px;
transition: width 0.2s ease;
}
.item-value {
font-size: 12px;
font-weight: 600;
min-width: 36px;
text-align: right;
flex-shrink: 0;
color: var(--text-muted);
}
/* ============================================================================
Responsive
============================================================================ */
@media (max-width: 820px) {
#list-container {
height: 400px;
}
.compression-bar {
flex-wrap: wrap;
gap: 6px;
}
}