Blog Posts

Native window scrolling with on-demand post loading
A long-running blog feed rendered in the browser window with dynamic heights (ResizeObserver). Posts are generated live and seeded by slug from lorem-api.com, images come from placehold.co, and new posts load on demand when you scroll to the end.

How to build a feature like this

A feed of long-form posts shapes the design around two facts: each post's rendered height is unknowable before its content renders (text wraps at the container width and a cover image has its own ratio), and such a list is usually your page's own content rather than a small fixed box. VirtualScroll takes the two axes separately: which element scrolls (:container - a bounded box, or the browser window), and how row sizes are known (arithmetic item-size for uniform rows, or per-row ResizeObserver measurement for variable ones). For posts, variable-height measured rows are the right model; a default-item-size estimate keeps scroll height and navigation sane until the first real measurements arrive. Fetching further content on demand - near the end of the list, or from a button - then appends to items and the engine re-ranges around the current scroll position.

1. Choose what scrolls, then give it the right data

By default <VirtualScroll> scrolls inside its own host element, which must then have a definite height (any explicit or flex/grid-allocated height, with min-height: 0 so it can shrink). When the list is the page, the cleaner choice is native page scrolling: pass the window through :container and the engine sizes its viewport from the page and listens to window scroll, so no fixed-height host is needed. Because window only exists client-side, hold it in a ref assigned in onMounted (harmless under SSR, where it stays null). With a window container the library uses native scrolling and does not enable coordinate scaling or virtual scrollbars, which are aimed at element scrollers.

The items array should hold the real post objects: the slot reads item.title, item.content, and so on, so each entry must carry its payload. (The data-less sparse-array pattern only fits rows whose content is derivable from the index - see the uniform list case below.)

<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { VirtualScroll } from '@pdanpdan/virtual-scroll';
import '@pdanpdan/virtual-scroll/style.css';

// A bounded element scroller needs no :container; a page-wide feed passes the
// window, assigned on mount because window only exists client-side.
const scrollContainer = ref<Window | null>(null);
onMounted(() => {
  scrollContainer.value = window;
});

const posts = ref<BlogPost[]>([]); // appended in batches
const loading = ref(false);
const hasMore = computed(() => posts.value.length < TOTAL_POSTS);

async function loadMore() {
  if (loading.value || !hasMore.value) return;
  loading.value = true;
  const batch = await fetchPosts(posts.value.length, BATCH_SIZE);
  posts.value = [ ...posts.value, ...batch ]; // appending keeps old indices stable
  loading.value = false;
}
</script>

2. Reserve the cover-image box so late loads cannot shift layout

A cover image that has not loaded yet renders as zero height, so a row would first paint short and then jump when the image arrives - moving the content the reader is looking at. That is a general image-list concern, not something virtualization can fix for you: give the <img> its intrinsic width and height attributes plus inline-size: 100%; block-size: auto. The browser then reserves the aspect-ratio box up front, so the row measures the same whether the image is pending or painted. The remaining height variance comes from wrapped prose, which only rendering can reveal - which is what dynamic measurement (next step) absorbs.

.feed {
  max-inline-size: 46rem; /* readable measure; the page itself keeps scrolling */
  margin-inline: auto;
}
.post {
  padding: 2.5rem 1.25rem;
  border-bottom: 1px solid rgb(0 0 0 / 0.1);
}
.cover {
  display: block;
  inline-size: 100%;
  block-size: auto; /* intrinsic w/h attrs reserve the aspect box up front */
  object-fit: cover;
  border-radius: 0.75rem;
}
.content {
  white-space: pre-line;
  line-height: 1.6;
}
.feed-loading {
  display: flex;
  justify-content: center;
  padding: 1.5rem;
}

3. Match the sizing model to your content

If every row is the same height you can pass a numeric item-size and the engine resolves positions arithmetically in O(1) - and because uniform rows are fully described by their index, you may pass a sparse array (new Array(n)) and render from the slot's index alone. Variable-height content needs the other model: leave item-size unset or set it to 0/null (all three select dynamic mode) and each mounted row is measured with ResizeObserver, updating the offset tree as rows settle. A measurement can only happen once a row is mounted, so pass default-item-size as an estimate (here 1000) for the initial scroll height and for far-target navigation; measured values replace it locally and only the affected range re-flows. When rows are tall, buffer-before / buffer-after count rows, not pixels - the default 5 keeps five extra rows mounted on each side, which for ~1,000px posts is a lot of DOM; 1 is usually enough.

<template>
  <VirtualScroll
    class="feed"
    :items="posts"
    :container="scrollContainer"
    :item-size="0"
    :default-item-size="1000"
    :buffer-before="1"
    :buffer-after="1"
    :load-distance="1600"
    :loading="loading"
    @load="loadMore"
  >
    <template #item="{ item }">
      <article class="post">
        <h2>{{ item.title }}</h2>
        <img
          :src="item.image"
          :width="item.imageWidth"
          :height="item.imageHeight"
          :alt="item.title"
          class="cover"
        />
        <div class="content">{{ item.content }}</div>
      </article>
    </template>

    <template v-if="hasMore" #loading>
      <div class="feed-loading">Loading more posts…</div>
    </template>
  </VirtualScroll>
</template>

4. Load the next batch as it is needed

load-distance (default 200; raise it for tall rows) is how far from the end, in pixels, the @load event fires. Drive it from state: keep a loading flag that both reveals your #loading slot while a fetch is in flight and suppresses repeated load events (early-return while true). The #loading slot stays mounted and merely hidden via CSS while loading is false, so it reserves its space - keeping the total scrollable height and the far-end scroll target correct while a fetch runs. Only render it while a load is actually expected (v-if="hasMore"), otherwise the reserved space lingers after the data runs out and you scroll past an empty stretch. Trigger the same handler from an explicit “Load more” button if you prefer paging over auto-load; the wiring is identical. Whatever the trigger, append the resolved batch to items so existing indices and offsets stay untouched.

0 / 400 posts

Lorem Blog

Seeded by virtual-scroll-blog - scroll to load more posts

Fetching more posts…
  • Scroll Status
  • Direction
    vertical
  • Current Item #
    -
  • Rendered Range #
    0:0
  • Total Size (px)
    0h
  • Viewport Size (px)
    0h
  • Scroll Offset (px)
    0y