/ Examples
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>