pageasync
🌍 Window Scroll
The list scrolls with the page — no inner scrollbar, no fixed container height. Just normal browser scrolling.
scrollElement: window
overflow: visible
native scrollbar
This page has a header, a list of
10,000 items loaded via async adapter, and
a footer — all in the normal document flow. The virtual list
uses scrollElement: window so the browser's
native scrollbar controls everything. Unloaded items show
skeleton placeholders that are replaced as
data arrives. Adjust the API delay to see the effect.
Search Results
Source
// Window Scroll Example
// Demonstrates scrollElement: window for document-level scrolling
// Uses adapter pattern with placeholders for async data loading
import { vlist, withPage, withAsync } from "vlist";
// Constants
const TOTAL_ITEMS = 10000;
const CATEGORIES = ["Article", "Video", "Image", "Document", "Audio"];
const COLORS = ["#667eea", "#43e97b", "#fa709a", "#f093fb", "#feca57"];
const DOMAINS = [
"example.com",
"docs.dev",
"blog.io",
"wiki.org",
"news.net",
"media.co",
"data.info",
"learn.edu",
];
// Simulated API — generates items with a realistic delay
let simulatedDelay = 300;
const generateItem = (id) => {
const catIndex = (id - 1) % CATEGORIES.length;
return {
id,
title: `${CATEGORIES[catIndex]}: Search result #${id}`,
description: `This is the description for result ${id}. It contains relevant information about the topic you searched for.`,
category: CATEGORIES[catIndex],
categoryColor: COLORS[catIndex],
domain: DOMAINS[(id - 1) % DOMAINS.length],
date: new Date(
Date.now() - Math.random() * 365 * 24 * 60 * 60 * 1000,
).toLocaleDateString(),
icon: CATEGORIES[catIndex][0],
};
};
const fetchItems = async (offset, limit) => {
await new Promise((resolve) => setTimeout(resolve, simulatedDelay));
const items = [];
const end = Math.min(offset + limit, TOTAL_ITEMS);
for (let i = offset; i < end; i++) {
items.push(generateItem(i + 1));
}
return { items, total: TOTAL_ITEMS, hasMore: end < TOTAL_ITEMS };
};
// Template — single template for both real items and placeholders.
// The renderer adds .vlist-item--placeholder on the wrapper element,
// so CSS handles the visual difference (skeleton blocks, shimmer, etc).
const itemTemplate = (item, index) => `
<div class="result-item">
<div class="result-icon" style="background: ${item.categoryColor || ""}">${item.icon || ""}</div>
<div class="result-body">
<div class="result-title">${item.title || ""}</div>
<div class="result-description">${item.description || ""}</div>
<div class="result-meta">
<span class="result-domain">${item.domain || ""}</span>
<span class="result-date">${item.date || ""}</span>
<span class="result-category" style="color: ${item.categoryColor || ""}">${item.category || ""}</span>
</div>
</div>
<div class="result-index">#${index + 1}</div>
</div>
`;
// Create the virtual list with window scrolling + adapter
const list = vlist({
container: "#list-container",
ariaLabel: "User directory",
item: {
height: 88,
template: itemTemplate,
},
})
.use(withPage())
.use(
withAsync({
adapter: {
read: async ({ offset, limit }) => {
return fetchItems(offset, limit);
},
},
}),
)
.build();
// Stats display
const statsEl = document.getElementById("stats");
let loadedCount = 0;
let updateScheduled = false;
const scheduleUpdate = () => {
if (updateScheduled) return;
updateScheduled = true;
requestAnimationFrame(() => {
updateStats();
updateScheduled = false;
});
};
const updateStats = () => {
const domNodes = document.querySelectorAll(".vlist-item").length;
const pct = Math.round((loadedCount / TOTAL_ITEMS) * 100);
statsEl.innerHTML = `
<span class="stat"><strong>${loadedCount.toLocaleString()}</strong> / ${TOTAL_ITEMS.toLocaleString()} loaded</span>
<span class="stat-sep">·</span>
<span class="stat"><strong>${domNodes}</strong> DOM nodes</span>
<span class="stat-sep">·</span>
<span class="stat"><strong>${pct}%</strong></span>
`;
};
list.on("scroll", scheduleUpdate);
list.on("range:change", scheduleUpdate);
list.on("load:end", ({ items }) => {
loadedCount += items.length;
scheduleUpdate();
});
updateStats();
// Navigation buttons
document.getElementById("btn-top").addEventListener("click", () => {
list.scrollToIndex(0, { align: "start", behavior: "smooth" });
});
document.getElementById("btn-middle").addEventListener("click", () => {
list.scrollToIndex(Math.floor(TOTAL_ITEMS / 2), {
align: "center",
behavior: "smooth",
});
});
document.getElementById("btn-bottom").addEventListener("click", () => {
list.scrollToIndex(TOTAL_ITEMS - 1, { align: "end", behavior: "smooth" });
});
// Delay control
const delayInput = document.getElementById("delay-input");
const delayValue = document.getElementById("delay-value");
if (delayInput) {
delayInput.addEventListener("input", () => {
simulatedDelay = parseInt(delayInput.value, 10);
delayValue.textContent = `${simulatedDelay}ms`;
});
}
// Log clicks
list.on("item:click", ({ item, index }) => {
if (!String(item.id).startsWith("__placeholder_")) {
console.log(`Clicked: ${item.title} at index ${index}`);
}
});
/* Window Scroll Example — example-specific styles only
Common styles are provided by examples/examples.css using shell.css design tokens.
This example has a custom layout (no .container) — it uses .page + .hero + sticky bar. */
/* ============================================================================
Sticky Stats Bar
============================================================================ */
.vlist {
min-height: 500px;
}
.sticky-bar {
position: sticky;
top: var(--sb-header-h, 0px);
z-index: 90;
display: flex;
align-items: center;
gap: 16px;
padding: 10px 20px;
background: var(--header-bg);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-bottom: 1px solid var(--border);
font-size: 13px;
}
.sticky-stats {
flex: 1;
display: flex;
align-items: center;
gap: 6px;
color: var(--text-muted);
min-width: 0;
}
.stat {
white-space: nowrap;
}
.stat strong {
color: var(--text);
}
.stat-sep {
color: var(--border);
}
.sticky-actions {
display: flex;
gap: 6px;
flex-shrink: 0;
}
.btn {
padding: 5px 12px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-card);
color: var(--text-muted);
font-size: 12px;
font-family: inherit;
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
}
.btn:hover {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
.btn:active {
transform: scale(0.96);
}
/* Delay control */
.sticky-controls {
flex-shrink: 0;
}
.delay-control {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--text-dim);
}
.delay-label {
white-space: nowrap;
}
.delay-control input[type="range"] {
width: 80px;
height: 4px;
accent-color: var(--accent);
cursor: pointer;
}
.delay-value {
min-width: 42px;
text-align: right;
font-variant-numeric: tabular-nums;
color: var(--text-muted);
font-weight: 500;
}
/* ============================================================================
Page Layout
============================================================================ */
.page {
max-width: 800px;
margin: 0 auto;
padding: 0 20px;
min-height: 1000px;
}
/* ============================================================================
Hero Header
============================================================================ */
.hero {
text-align: center;
padding: 60px 20px 40px;
}
.hero h1 {
font-size: 48px;
font-weight: 700;
color: var(--text);
margin-bottom: 12px;
}
.hero-subtitle {
font-size: 18px;
color: var(--text-muted);
line-height: 1.6;
max-width: 560px;
margin: 0 auto 20px;
}
.hero-badges {
display: flex;
justify-content: center;
gap: 10px;
flex-wrap: wrap;
}
.badge {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
padding: 5px 14px;
border-radius: 20px;
font-size: 12px;
font-weight: 500;
font-family: "Monaco", "Menlo", "Consolas", monospace;
letter-spacing: -0.2px;
}
/* ============================================================================
Intro Section
============================================================================ */
.intro {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 20px 24px;
margin-bottom: 24px;
line-height: 1.7;
font-size: 15px;
color: var(--text-muted);
}
.intro code {
background: var(--bg);
border: 1px solid var(--border);
padding: 2px 7px;
border-radius: 4px;
font-size: 13px;
color: var(--accent-text);
font-weight: 500;
}
.intro strong {
color: var(--text);
}
/* ============================================================================
List Section
============================================================================ */
.list-section {
margin-bottom: 32px;
}
.section-title {
font-size: 14px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-dim);
margin-bottom: 12px;
padding-left: 4px;
}
/* The vlist viewport — no fixed height in window mode, it flows naturally */
#list-container {
border-radius: 12px;
overflow: hidden;
}
/* ============================================================================
Search Result Items
============================================================================ */
.result-item {
display: flex;
align-items: center;
gap: 14px;
height: 100%;
}
.result-icon {
width: 40px;
height: 40px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-weight: 700;
font-size: 16px;
flex-shrink: 0;
}
.result-body {
flex: 1;
min-width: 0;
}
.result-title {
font-weight: 600;
font-size: 14px;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 2px;
}
.result-description {
font-size: 13px;
color: var(--text-dim);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 4px;
}
.result-meta {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--text-dim);
}
.result-domain {
color: var(--accent-text);
font-weight: 500;
}
.result-sep {
color: var(--border);
}
.result-date {
color: var(--text-dim);
}
.result-category {
font-weight: 500;
}
.result-index {
font-size: 11px;
color: var(--border-hover);
min-width: 50px;
text-align: right;
flex-shrink: 0;
font-variant-numeric: tabular-nums;
}
/* ============================================================================
Placeholder Skeletons — driven by .vlist-item--placeholder on the wrapper.
The template is identical for real and placeholder items; mask characters
(x) set the natural width, CSS hides them and shows skeleton blocks.
============================================================================ */
.vlist-item--placeholder .result-icon {
background: var(--border) !important;
color: transparent;
}
.vlist-item--placeholder .result-title,
.vlist-item--placeholder .result-description,
.vlist-item--placeholder .result-domain,
.vlist-item--placeholder .result-date,
.vlist-item--placeholder .result-category {
color: transparent;
background: var(--border);
border-radius: 4px;
width: fit-content;
}
.vlist-item--placeholder .result-index {
color: transparent;
}
.vlist-item--placeholder .result-sep {
visibility: hidden;
}
/* Item hover */
.vlist-item:hover {
background: var(--sidebar-hover);
}
/* ============================================================================
Footer
============================================================================ */
.page-footer {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 12px;
padding: 48px 32px;
text-align: center;
margin-bottom: 40px;
}
.footer-text {
font-size: 18px;
color: #fff;
line-height: 1.6;
max-width: 500px;
margin: 0 auto 12px;
}
.footer-meta {
font-size: 13px;
color: rgba(255, 255, 255, 0.6);
}
/* ============================================================================
Responsive
============================================================================ */
@media (max-width: 600px) {
.hero h1 {
font-size: 32px;
}
.hero-subtitle {
font-size: 16px;
}
.sticky-bar {
padding: 8px 12px;
gap: 10px;
}
.sticky-stats {
font-size: 12px;
}
.sticky-controls,
.sticky-actions {
display: none;
}
.result-item {
padding: 0 14px;
}
.result-index {
display: none;
}
.page {
padding: 0 12px;
}
}
<div class="sticky-bar" id="sticky-bar">
<div class="sticky-stats" id="stats">Loading...</div>
<div class="sticky-controls">
<label class="delay-control">
<span class="delay-label">Delay</span>
<input
type="range"
id="delay-input"
min="0"
max="2000"
value="300"
step="100"
/>
<span class="delay-value" id="delay-value">300ms</span>
</label>
</div>
<div class="sticky-actions">
<button class="btn" id="btn-top">↑ Top</button>
<button class="btn" id="btn-middle">Middle</button>
<button class="btn" id="btn-bottom">Bottom ↓</button>
</div>
</div>
<div class="page">
<header class="hero">
<h1>🌍 Window Scroll</h1>
<p class="hero-subtitle">
The list scrolls with the page — no inner scrollbar, no
fixed container height. Just normal browser scrolling.
</p>
<div class="hero-badges">
<span class="badge">scrollElement: window</span>
<span class="badge">overflow: visible</span>
<span class="badge">native scrollbar</span>
</div>
</header>
<section class="intro">
<p>
This page has a header, a list of
<strong>10,000 items</strong> loaded via async adapter, and
a footer — all in the normal document flow. The virtual list
uses <code>scrollElement: window</code> so the browser's
native scrollbar controls everything. Unloaded items show
<strong>skeleton placeholders</strong> that are replaced as
data arrives. Adjust the API delay to see the effect.
</p>
</section>
<section class="list-section">
<h2 class="section-title">Search Results</h2>
<div id="list-container"></div>
</section>
<footer class="page-footer">
<p class="footer-text">
🎉 You reached the footer! This element sits below the
virtual list in the normal page flow. The browser scrollbar
handles everything — vlist just virtualizes the items.
</p>
<p class="footer-meta">
vlist · Window Scroll Example · Zero Dependencies
</p>
</footer>
</div>