scalescrollbar
Large List
Svelte implementation with vlist action +
withCompression + withScrollbar plugins.
Handles 100K–5M items with automatic scroll compression when total
height exceeds the browser's 16.7M pixel limit.
Loading…
Source
// Large List — Svelte implementation with vlist action
// Uses builder pattern with compression + scrollbar plugins
// Demonstrates handling 100K–5M items with automatic scroll compression
import { vlist, onVListEvent } from "vlist-svelte";
// =============================================================================
// 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",
];
// =============================================================================
// Utilities
// =============================================================================
// Simple hash for consistent per-item values
const hash = (n) => {
let h = (n + 1) * 2654435761;
h ^= h >>> 16;
return Math.abs(h);
};
// Generate items on the fly
const generateItems = (count) =>
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],
}));
// Item template
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>
`;
// =============================================================================
// DOM references
// =============================================================================
const statsEl = document.getElementById("stats");
const compressionEl = document.getElementById("compression-info");
const scrollPosEl = document.getElementById("scroll-position");
const scrollDirEl = document.getElementById("scroll-direction");
const rangeEl = document.getElementById("visible-range");
const sizeButtons = document.getElementById("size-buttons");
const container = document.getElementById("list-container");
// =============================================================================
// State
// =============================================================================
let currentSize = "1m";
let action = null;
let listInstance = null;
// =============================================================================
// Stats functions
// =============================================================================
let statsRaf = null;
function scheduleStatsUpdate() {
if (statsRaf) return;
statsRaf = requestAnimationFrame(() => {
statsRaf = null;
updateStats(SIZES[currentSize]);
updateCompressionInfo(SIZES[currentSize]);
});
}
function updateStats(count, genTime, buildTime) {
const domNodes = document.querySelectorAll(".vlist-item").length;
const virtualized = ((1 - domNodes / count) * 100).toFixed(4);
let html = `<strong>Total:</strong> ${count.toLocaleString()}`;
html += ` · <strong>DOM:</strong> ${domNodes}`;
html += ` · <strong>Virtualized:</strong> ${virtualized}%`;
if (genTime !== undefined) {
html += ` · <strong>Gen:</strong> ${genTime.toFixed(0)}ms`;
}
if (buildTime !== undefined) {
html += ` · <strong>Build:</strong> ${buildTime.toFixed(0)}ms`;
}
statsEl.innerHTML = html;
}
function updateCompressionInfo(count) {
const totalHeight = count * ITEM_HEIGHT;
const maxHeight = 16_777_216; // browser limit ~16.7M px
const isCompressed = totalHeight > maxHeight;
const ratio = isCompressed ? (totalHeight / maxHeight).toFixed(1) : "1.0";
let html = `<span class="compression-badge ${isCompressed ? "compression-badge--active" : "compression-badge--off"}">`;
html += isCompressed ? "COMPRESSED" : "NATIVE";
html += "</span>";
html += ` <span class="compression-detail">`;
html += `Virtual height: <strong>${(totalHeight / 1_000_000).toFixed(1)}M px</strong>`;
html += ` · Ratio: <strong>${ratio}×</strong>`;
html += ` · Limit: <strong>16.7M px</strong>`;
html += `</span>`;
compressionEl.innerHTML = html;
}
// =============================================================================
// Create / Recreate list
// =============================================================================
function createList(sizeKey) {
// Destroy previous action
if (action && action.destroy) {
action.destroy();
action = null;
listInstance = null;
}
// Clear container
container.innerHTML = "";
const count = SIZES[sizeKey];
const startTime = performance.now();
const items = generateItems(count);
const genTime = performance.now() - startTime;
// Create vlist action
action = vlist(container, {
config: {
ariaLabel: `${count.toLocaleString()} items list`,
item: {
height: ITEM_HEIGHT,
template: itemTemplate,
},
items,
plugins: [
{
name: "compression",
config: {},
},
{
name: "scrollbar",
config: { autoHide: true },
},
],
},
onInstance: (inst) => {
const buildTime = performance.now() - startTime;
// Store instance for navigation controls
listInstance = inst;
// Bind events
onVListEvent(listInstance, "scroll", ({ scrollTop, direction }) => {
scrollPosEl.textContent = `${Math.round(scrollTop).toLocaleString()}px`;
scrollDirEl.textContent = direction === "up" ? "↑ up" : "↓ down";
scheduleStatsUpdate();
});
onVListEvent(listInstance, "range:change", ({ range }) => {
rangeEl.textContent = `${range.start.toLocaleString()} – ${range.end.toLocaleString()}`;
scheduleStatsUpdate();
});
// Show initial stats
updateStats(count, genTime, buildTime);
updateCompressionInfo(count);
},
});
}
// =============================================================================
// 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("panel-segmented__btn--active", b.dataset.size === size);
});
createList(size);
});
// =============================================================================
// Navigation controls
// =============================================================================
document.getElementById("btn-first").addEventListener("click", () => {
listInstance?.scrollToIndex(0, "start");
});
document.getElementById("btn-middle").addEventListener("click", () => {
listInstance?.scrollToIndex(Math.floor(SIZES[currentSize] / 2), "center");
});
document.getElementById("btn-last").addEventListener("click", () => {
listInstance?.scrollToIndex(SIZES[currentSize] - 1, "end");
});
document.getElementById("btn-random").addEventListener("click", () => {
const idx = Math.floor(Math.random() * SIZES[currentSize]);
listInstance?.scrollToIndex(idx, "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;
listInstance?.scrollToIndex(
Math.max(0, Math.min(idx, SIZES[currentSize] - 1)),
align,
);
});
document.getElementById("scroll-index").addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
document.getElementById("btn-go").click();
}
});
document.getElementById("btn-smooth-top").addEventListener("click", () => {
listInstance?.scrollToIndex(0, {
align: "start",
behavior: "smooth",
duration: 800,
});
});
document.getElementById("btn-smooth-bottom").addEventListener("click", () => {
listInstance?.scrollToIndex(SIZES[currentSize] - 1, {
align: "end",
behavior: "smooth",
duration: 800,
});
});
// =============================================================================
// Initialise with 1M items
// =============================================================================
createList(currentSize);
<div class="container container">
<header>
<h1>Large List</h1>
<p class="description">
Svelte implementation with <code>vlist</code> action +
<code>withCompression</code> + <code>withScrollbar</code> plugins.
Handles 100K–5M items with automatic scroll compression when total
height exceeds the browser's 16.7M pixel limit.
</p>
</header>
<div class="stats" id="stats">Loading…</div>
<div class="compression-bar" id="compression-info"></div>
<div class="split-layout">
<div class="split-main split-main--full">
<div id="list-container"></div>
</div>
<aside class="split-panel">
<!-- Size -->
<section class="panel-section">
<h3 class="panel-title">Size</h3>
<div class="panel-row">
<div class="panel-segmented" id="size-buttons">
<button class="panel-segmented__btn" data-size="100k">
100K
</button>
<button class="panel-segmented__btn" data-size="500k">
500K
</button>
<button
class="panel-segmented__btn panel-segmented__btn--active"
data-size="1m"
>
1M
</button>
<button class="panel-segmented__btn" data-size="2m">
2M
</button>
<button class="panel-segmented__btn" data-size="5m">
5M
</button>
</div>
</div>
</section>
<!-- Navigation -->
<section class="panel-section">
<h3 class="panel-title">Navigation</h3>
<div class="panel-row">
<label class="panel-label" for="scroll-index"
>Scroll to index</label
>
<div class="panel-input-group">
<input
type="number"
id="scroll-index"
min="0"
value="0"
class="panel-input"
/>
<select id="scroll-align" class="panel-select">
<option value="start">start</option>
<option value="center">center</option>
<option value="end">end</option>
</select>
<button
id="btn-go"
class="panel-btn panel-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="panel-row">
<label class="panel-label">Quick jump</label>
<div class="panel-btn-group">
<button id="btn-first" class="panel-btn">First</button>
<button id="btn-middle" class="panel-btn">
Middle
</button>
<button id="btn-last" class="panel-btn">Last</button>
<button id="btn-random" class="panel-btn">
Random
</button>
</div>
</div>
<div class="panel-row">
<label class="panel-label">Smooth scroll</label>
<div class="panel-btn-group">
<button id="btn-smooth-top" class="panel-btn">
↑ Top
</button>
<button id="btn-smooth-bottom" class="panel-btn">
↓ Bottom
</button>
</div>
</div>
</section>
<!-- Viewport -->
<section class="panel-section">
<h3 class="panel-title">Viewport</h3>
<div class="panel-row">
<span class="panel-label">Scroll</span>
<span class="panel-value" id="scroll-position">0px</span>
</div>
<div class="panel-row">
<span class="panel-label">Direction</span>
<span class="panel-value" id="scroll-direction">–</span>
</div>
<div class="panel-row">
<span class="panel-label">Range</span>
<span class="panel-value" id="visible-range">–</span>
</div>
</section>
</aside>
</div>
<footer>
<p>
Compression activates automatically when the virtual height exceeds
~16.7 million pixels. The Svelte action integrates seamlessly with
the builder's plugin system — compression logic is only loaded when
you configure the <code>compression</code> plugin. 🎯
</p>
</footer>
</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.
Panel system (.split-layout, .split-panel, .panel-*)
is also provided by example/example.css. */
/* List container */
#list-container {
height: 600px;
margin: 0 auto;
}
/* ============================================================================
Size Selector
============================================================================ */
.size-selector {
display: flex;
gap: 6px;
margin-bottom: 12px;
}
.size-btn {
padding: 6px 16px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-card);
color: var(--text-muted);
font-size: 13px;
font-weight: 600;
font-family: inherit;
cursor: pointer;
transition: all 0.15s ease;
flex: 1;
}
.size-btn:hover {
border-color: var(--accent);
color: var(--accent-text);
}
.size-btn--active {
background: var(--accent);
color: white;
border-color: var(--accent);
}
/* ============================================================================
Compression Bar
============================================================================ */
.compression-bar {
display: flex;
align-items: center;
gap: 10px;
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);
}
.compression-badge {
display: inline-block;
padding: 2px 10px;
border-radius: 12px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.5px;
text-transform: uppercase;
flex-shrink: 0;
}
.compression-badge--active {
background: #ff6b6b;
color: white;
}
.compression-badge--off {
background: #51cf66;
color: white;
}
.compression-detail {
font-size: 12px;
color: var(--text-muted);
}
.compression-detail strong {
color: var(--text);
}
/* ============================================================================
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: "SF Mono", Monaco, Menlo, monospace;
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;
}
.size-selector {
flex-wrap: wrap;
}
.size-btn {
flex: 0 0 auto;
padding: 6px 12px;
}
.compression-bar {
flex-wrap: wrap;
gap: 6px;
}
}