Horizontal Variable Width
Pure vanilla JavaScript demonstrating horizontal virtual scrolling
with dynamic item widths. Each of the 1,000 items
has a different width (120–340px) calculated via
width: (index) => items[index].width. Featured items
are wider. Only visible items are in the DOM, regardless of their
varying widths.
Loading...
Source
// Horizontal Variable Width — Vanilla JavaScript
// Demonstrates orientation: 'horizontal' with dynamic item.width function
import { createVList } from "vlist";
import {
items,
itemTemplate,
formatStatsHtml,
ITEM_HEIGHT,
} from "../shared.js";
// Create horizontal virtual list with variable widths
const list = createVList({
container: "#list-container",
orientation: "horizontal",
scroll: { wheel: true },
ariaLabel: "Horizontal variable width carousel",
item: {
height: ITEM_HEIGHT,
// Dynamic width function - each item has different width
width: (index) => items[index].width,
template: itemTemplate,
},
items,
});
// Update stats display
const statsEl = document.getElementById("stats");
const updateStats = () => {
const domNodes = document.querySelectorAll(".vlist-item").length;
statsEl.innerHTML = formatStatsHtml(domNodes, items.length);
};
list.on("scroll", updateStats);
list.on("range:change", updateStats);
updateStats();
// Log clicks
list.on("item:click", ({ item, index }) => {
console.log(`Clicked: ${item.title} (${item.width}px) at index ${index}`);
});
// Control buttons
document.getElementById("btn-start")?.addEventListener("click", () => {
list.scrollToIndex(0);
});
// Find first featured item
document.getElementById("btn-featured")?.addEventListener("click", () => {
const featuredIndex = items.findIndex((item) => item.isFeatured);
if (featuredIndex >= 0) {
list.scrollToIndex(featuredIndex, "center");
}
});
document.getElementById("btn-center")?.addEventListener("click", () => {
list.scrollToIndex(500, "center");
});
document.getElementById("btn-end")?.addEventListener("click", () => {
list.scrollToIndex(items.length - 1, "end");
});
document.getElementById("btn-smooth")?.addEventListener("click", () => {
const current = list.getScrollPosition();
const targetIndex = current < 100 ? 300 : 0;
list.scrollToIndex(targetIndex, {
align: "start",
behavior: "smooth",
duration: 1000,
});
});
<div class="container">
<header>
<h1>Horizontal Variable Width</h1>
<p class="description">
Pure vanilla JavaScript demonstrating horizontal virtual scrolling
with <strong>dynamic item widths</strong>. Each of the 1,000 items
has a different width (120–340px) calculated via
<code>width: (index) => items[index].width</code>. Featured items
are wider. Only visible items are in the DOM, regardless of their
varying widths.
</p>
</header>
<div class="stats" id="stats">Loading...</div>
<div id="list-container"></div>
<div class="controls">
<button id="btn-start">⏮ Start</button>
<button id="btn-featured">⭐ First Featured</button>
<button id="btn-center">⏺ Center (500)</button>
<button id="btn-end">⏭ End</button>
<button id="btn-smooth">🎞 Smooth Scroll</button>
</div>
<footer>
<p>
Uses <code>orientation: 'horizontal'</code> with a
<strong>dynamic width function</strong>:
<code>width: (index) => items[index].width</code>. The height
cache efficiently handles variable widths using prefix sums for
O(1) offset lookups and O(log n) binary search for index
positioning. Notice how items of different widths scroll smoothly
with perfect positioning. ✨
</p>
</footer>
</div>
// Shared data and utilities for horizontal variable width example
// This file is imported by all framework implementations to avoid duplication
// =============================================================================
// Constants
// =============================================================================
export const ITEM_COUNT = 1000;
export const ITEM_HEIGHT = 200;
// Color palette for cards
export const colors = [
["#667eea", "#764ba2"],
["#f093fb", "#f5576c"],
["#4facfe", "#00f2fe"],
["#43e97b", "#38f9d7"],
["#fa709a", "#fee140"],
["#a18cd1", "#fbc2eb"],
["#fccb90", "#d57eeb"],
["#e0c3fc", "#8ec5fc"],
["#f5576c", "#ff9a9e"],
["#667eea", "#00f2fe"],
];
export const categories = [
"Photo",
"Video",
"Music",
"Document",
"Archive",
"Code",
"Design",
"Text",
];
export const icons = ["📷", "🎬", "🎵", "📄", "📦", "💻", "🎨", "📝"];
// Width patterns - creates visual variety
const widthPatterns = [120, 180, 240, 160, 200, 140, 220, 190];
// =============================================================================
// Data Generation
// =============================================================================
// Generate items with variable widths
export const items = Array.from({ length: ITEM_COUNT }, (_, i) => {
const categoryIndex = i % categories.length;
const color = colors[i % colors.length];
// Width varies based on pattern (simulating different content sizes)
const width = widthPatterns[i % widthPatterns.length];
// Some items are "featured" (wider)
const isFeatured = i % 17 === 0;
const finalWidth = isFeatured ? width + 100 : width;
return {
id: i + 1,
title: isFeatured ? `⭐ Featured #${i + 1}` : `Item ${i + 1}`,
category: categories[categoryIndex],
icon: icons[categoryIndex],
width: finalWidth,
number: i + 1,
gradientStart: color[0],
gradientEnd: color[1],
isFeatured,
};
});
// =============================================================================
// Templates
// =============================================================================
// Card template - width is set by vlist
export const itemTemplate = (item) => {
const featuredClass = item.isFeatured ? "card--featured" : "";
return `
<div class="card ${featuredClass}" style="background: linear-gradient(135deg, ${item.gradientStart} 0%, ${item.gradientEnd} 100%);">
<div class="card-icon">${item.icon}</div>
<div class="card-content">
<div class="card-title">${item.title}</div>
<div class="card-category">${item.category}</div>
<div class="card-width">${item.width}px</div>
</div>
${item.isFeatured ? '<div class="ui-badge ui-badge--pill ui-badge--glass">Featured</div>' : ""}
</div>
`;
};
// =============================================================================
// Utilities
// =============================================================================
// Calculate stats
export function calculateStats(domNodes, total) {
const saved = Math.round((1 - domNodes / total) * 100);
// Calculate width statistics
const widths = items.map((item) => item.width);
const minWidth = Math.min(...widths);
const maxWidth = Math.max(...widths);
const avgWidth = Math.round(
widths.reduce((a, b) => a + b, 0) / widths.length,
);
const totalWidth = widths.reduce((a, b) => a + b, 0);
return { saved, minWidth, maxWidth, avgWidth, totalWidth };
}
// Format stats HTML
export function formatStatsHtml(domNodes, total) {
const { saved, minWidth, maxWidth, avgWidth } = calculateStats(
domNodes,
total,
);
return `
<span><strong>Total:</strong> ${total.toLocaleString()} items</span>
<span><strong>DOM nodes:</strong> ${domNodes}</span>
<span><strong>Memory saved:</strong> ${saved}%</span>
<span><strong>Width:</strong> ${minWidth}–${maxWidth}px (avg ${avgWidth}px)</span>
`;
}
/* Horizontal Variable Width Example — example-specific styles only
Common styles (.container, h1, .description, .stats, footer)
are provided by example/example.css using shell.css design tokens. */
/* List container - full width, responsive height */
#list-container {
width: 100%;
max-width: 100%;
min-width: 0;
height: 200px;
overflow: hidden;
}
/* Override vlist defaults for horizontal cards */
#list-container .vlist {
border: none;
border-radius: 12px;
background: transparent;
max-width: 100%;
width: 100%;
box-sizing: border-box;
overflow-x: auto;
}
#list-container .vlist-viewport {
max-width: 100%;
width: 100% !important;
overflow-x: auto;
}
#list-container .vlist-item {
padding: 8px;
border-right: none;
cursor: pointer;
/* Width is set dynamically by vlist */
}
/* Card styles */
.card {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
height: 100%;
border-radius: 12px;
color: #fff;
text-align: center;
padding: 16px 12px;
position: relative;
transition:
transform 0.15s ease-out,
box-shadow 0.15s ease-out;
}
.vlist-item:hover .card {
transform: scale(1.03);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
}
/* Featured cards - wider items */
.card--featured {
border: 2px solid rgba(255, 255, 255, 0.3);
}
.card-icon {
font-size: 32px;
line-height: 1;
}
.card-content {
display: flex;
flex-direction: column;
gap: 4px;
width: 100%;
}
.card-title {
font-weight: var(--fw-bold);
font-size: 14px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
.card-category {
font-size: 11px;
opacity: 0.85;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.card-width {
font-size: 10px;
opacity: 0.7;
font-family: var(--font-mono);
}
.card .ui-badge--glass {
position: absolute;
top: 8px;
right: 8px;
}
/* Bottom controls */
.controls {
margin-top: 16px;
display: flex;
gap: 8px;
flex-wrap: wrap;
}
/* ============================================================================
Responsive
============================================================================ */
@media (max-width: 900px) {
#list-container {
height: 180px;
}
#list-container .vlist-item {
padding: 6px;
}
.card {
padding: 12px 10px;
border-radius: 10px;
}
.card-icon {
font-size: 28px;
}
.card-title {
font-size: 13px;
}
}
@media (max-width: 720px) {
#list-container {
height: 160px;
}
#list-container .vlist-item {
padding: 4px;
}
.card {
padding: 10px 8px;
gap: 6px;
border-radius: 8px;
}
.card-icon {
font-size: 24px;
}
.card-title {
font-size: 12px;
}
.card-category {
font-size: 10px;
}
.card-width {
font-size: 9px;
}
.card .ui-badge--glass {
font-size: 8px;
padding: 2px 6px;
}
.controls {
flex-direction: column;
gap: 8px;
}
.controls button {
width: 100%;
font-size: 13px;
}
}