Skip to content

Infinite scroll

Example code

Styling

body {
  --darker-bg-color: rgb(71, 81, 91);
  --lighter-bg-color: lightslategray;
  background-color: var(--lighter-bg-color);
}

.lv-scroller * {
  box-sizing: border-box;
  padding: 0;
  margin: 0;
  color: white;
}

.lv-scroller {
  width: 340px;
  height: 460px;
  padding: 16px 16px 40px 16px;
  background-color: var(--darker-bg-color);
  resize: both;
}

.lv-viewport {
  container-type: size;
  width: 100%;
  height: 100%;
  border: 1px solid var(--lighter-bg-color);
  border-radius: 8px;
  background-color: var(--lighter-bg-color);
  box-shadow: inset 0px 0px 18px 2px var(--darker-bg-color);
}

.lv-content-layer {
  --min-col-size: 220px;
  gap: 1cqh 1cqw;
  justify-content: center;
  grid-template-columns: repeat(
    auto-fit,
    minmax(min(var(--min-col-size), 100%), 1fr)
  );
}

.loader-container {
  grid-column: 1 / -1;
  display: flex;
  height: 64px;
  align-items: center;
  justify-content: center;
}

.loader {
  width: 15px;
  aspect-ratio: 1;
  border-radius: 50%;
  animation: l5 1s infinite linear alternate;
}

@keyframes l5 {
    0%  {box-shadow: 20px 0 #fff, -20px 0 #fff2;background: #fff }
    33% {box-shadow: 20px 0 #fff, -20px 0 #fff2;background: #fff2}
    66% {box-shadow: 20px 0 #fff2,-20px 0 #fff; background: #fff2}
    100%{box-shadow: 20px 0 #fff2,-20px 0 #fff; background: #fff }
}

.article-card {
  position: relative;
  display: flex;
  gap: 16px;
  flex-direction: column;
  overflow: hidden;
  border-radius: 8px;
  background-color: var(--darker-bg-color);
  padding: 12px;
}

.ac-index {
  position: absolute;
  top: 8px;
  right: 8px;
  padding: 2px 6px;
  border-radius: 4px;
  background-color: rgba(0, 0, 0, 0.5);
  backdrop-filter: blur(4px);
  font-size: 10px;
  line-height: 1.4;
  letter-spacing: 0.02em;
}

.ac-title {
  font-size: clamp(14px, 2.6cqw, 20px);
  font-weight: 600;
  line-height: 1.3;
  max-width: 85%;
}

.ac-body {
  font-size: clamp(12px, 2cqw, 15px);
  line-height: 1.5;
  opacity: 0.8;
}

.ac-button {
  align-self: flex-start;
  margin-top: 4px;
  padding: 6px 14px;
  border: 1px solid currentColor;
  border-radius: 999px;
  background: none;
  color: inherit;
  font-size: 13px;
  cursor: pointer;
}

.ac-button:hover {
  background-color: var(--lighter-bg-color);
}

Vanilla

import LayoutVirtual, { type ListItemProps, type VirtualizedListDOMClasses } from 'layout-virtual';

const styling: VirtualizedListDOMClasses = {
  scrollerClass: 'lv-scroller',
  viewportClass: 'lv-viewport',
  contentLayerClass: 'lv-content-layer',
};

const SHOW_LOADER = Symbol('show-loader');
type Post = { title: string; body: string } | typeof SHOW_LOADER;

const loadedUrls = new Set<string>();
let data: Post[] = [];
let dataLimit = 100;
let isLoading = false;

function Loader(index: number): HTMLElement {
  const container = document.createElement('div');
  const spinner = document.createElement('div');
  container.className = 'loader-container';
  container.dataset.index = index.toString();
  spinner.className = 'loader';
  container.append(spinner);
  return container;
}

function ArticleCard({ data: item, index }: ListItemProps<Post>): HTMLElement {
  if (item === SHOW_LOADER) return Loader(index);

  const { title, body } = item as { title: string; body: string };
  const card = document.createElement('div');
  const indexEl = document.createElement('span');
  const titleEl = document.createElement('h3');
  const bodyEl = document.createElement('p');
  const button = document.createElement('button');

  card.className = 'article-card';
  card.dataset.index = index.toString();
  indexEl.className = 'ac-index';
  indexEl.textContent = String(index);
  titleEl.className = 'ac-title';
  titleEl.textContent = title;
  bodyEl.className = 'ac-body';
  bodyEl.textContent = body;
  button.className = 'ac-button';
  button.textContent = 'Learn more';

  card.append(indexEl, titleEl, bodyEl, button);
  return card;
}

function getListData(): Post[] {
  return isLoading ? data.concat(SHOW_LOADER) : data;
}

function onAfterRender(startIndex: number, endIndex: number) {
  console.log('onAfterItemsRendered', startIndex, endIndex);
  const total = endIndex - startIndex + 1;
  stats.textContent = `Rendered indices ${startIndex} - ${endIndex}, total ${total}. Loaded ${data.length} of ${dataLimit}.`;

  if (endIndex === data.length - 1 && endIndex < dataLimit - 1 && !isLoading) {
    console.log('will try to fetch data');
    loadMore(10, data.length);
  }
}

function loadMore(limit: number, skip: number) {
  const apiUrl = `https://dummyjson.com/posts?limit=${limit}&skip=${skip}&select=title,body`;
  if (loadedUrls.has(apiUrl)) return;

  isLoading = true;
  loadedUrls.add(apiUrl);

  console.log('will fetch', apiUrl);

  api.setData(getListData());

  fetch(apiUrl)
    .then(r => r.json())
    .then(result => {
      dataLimit = result.total;
      data = data.concat(result.posts);
    })
    .catch(console.error)
    .finally(() => {
      isLoading = false;
      api.setData(getListData());
    });
}

const title = document.createElement('h4');
title.textContent = 'Try resizing the container and scroll.';
const stats = document.createElement('div');

const { container, api } = LayoutVirtual<Post>({
  overscanHeight: 200,
  data: getListData(),
  renderItem: ArticleCard,
  ...styling,
  onAfterItemsRendered: onAfterRender,
});

const app = document.getElementById('app')!;
app.append(title, stats, container);

loadMore(10, 0);

React

import { useCallback, useMemo, useState, useRef } from 'react';
import LayoutVirtual, { type VirtualizedListReactClasses, type ListItemProps } from 'react-layout-virtual';

const styling: VirtualizedListReactClasses = {
  scrollerClass: 'lv-scroller',
  viewportClass: 'lv-viewport',
  contentLayerClass: 'lv-content-layer',
};

const SHOW_LOADER = Symbol('show-loader');

type Post = { title: string; body: string } | typeof SHOW_LOADER;

function Loader({ index }: Omit<ListItemProps<Post>, 'data'>) {
  return <div className='loader-container' data-index={index}><div className='loader'></div></div>;
}

function ArticleCard({ data, index }: ListItemProps<Post>) {
  if (data === SHOW_LOADER) {
    return <Loader index={index} />;
  }

  const { title, body } = data;

  return (
    <div className={'article-card'} data-index={index}>
      <span className='ac-index'>{index}</span>
      <h3 className={'ac-title'}>{title}</h3>
      <p className={'ac-body'}>{body}</p>
      <button className={'ac-button'}>Learn more</button>
    </div>
  );
}

const InfiniteScrollExample = () => {
  const loadedUrls = useRef(new Set());
  const [ data, setData ] = useState<Post[]>([]);
  const [ dataLimit, setDataLimit ] = useState(100);
  const [isLoading, setIsLoading] = useState(false);
  const listData = useMemo(() => isLoading ? data.concat(SHOW_LOADER) : data, [isLoading, data]);
  const [renderedIndices, setRenderedIndices] = useState({ startIndex: 0, endIndex: 0 });
  const { startIndex, endIndex } = renderedIndices;
  const total = endIndex - startIndex + 1;

  const loadMore = useCallback((limit: number, skip: number) => {
    const apiUrl = `https://dummyjson.com/posts?limit=${limit}&skip=${skip}&select=title,body`;

    if (loadedUrls.current.has(apiUrl)) return;
    
    setIsLoading(true);
    loadedUrls.current.add(apiUrl);

    console.log('will fetch', apiUrl);

    fetch(apiUrl)
      .then(response => response.json())
      .then(result => (setDataLimit(result.total), result))
      .then(result => setData(prevPosts => prevPosts.concat(result.posts)))
      .catch(console.error)
      .finally(() => setIsLoading(false));
  }, []);

  const onAfterRender = useCallback((startIndex: number, endIndex: number) => {
    console.log('onAfterItemsRendered', startIndex, endIndex);

    if (endIndex === data.length - 1 && endIndex < dataLimit - 1) {
      if (!isLoading) {
        console.log('will try to fetch data');
        loadMore(10, data.length);
      }
    }

    setRenderedIndices({ startIndex, endIndex });
  }, [data, dataLimit, loadMore, isLoading]);

  return (
    <>
      <h4>Try resizing the container and scroll.</h4>
      <div>Rendered indices {startIndex} - {endIndex}, total {total}. Loaded {data.length} of {dataLimit}.</div>
      <LayoutVirtual<Post> overscanHeight={200} data={listData} renderItem={ArticleCard} {...styling} onAfterItemsRendered={onAfterRender} />
    </>
  );
};

export default InfiniteScrollExample;

Vue

<script setup lang="ts" generic="T">
import { ref, computed } from 'vue';
import LayoutVirtual from 'vue-layout-virtual';
import type { VirtualizedListVueClasses } from 'vue-layout-virtual';

const styling: VirtualizedListVueClasses = {
  scrollerClass: 'lv-scroller',
  viewportClass: 'lv-viewport',
  contentLayerClass: 'lv-content-layer',
};

const SHOW_LOADER = Symbol('show-loader');
type Post = { title: string; body: string } | typeof SHOW_LOADER;

const isLoading = ref(false);
const loadedUrls = new Set<string>();
const data = ref<Post[]>([]);
const dataLimit = ref(100);
const listData = computed(() => isLoading.value ? data.value.concat(SHOW_LOADER) : data.value);
const startIndex = ref(0);
const endIndex = ref(0);
const total = computed(() => endIndex.value - startIndex.value + 1);

function loadMore(limit: number, skip: number) {
  const apiUrl = `https://dummyjson.com/posts?limit=${limit}&skip=${skip}&select=title,body`;
  if (loadedUrls.has(apiUrl)) return;

  isLoading.value = true;
  loadedUrls.add(apiUrl);
  console.log('will fetch', apiUrl);

  fetch(apiUrl)
    .then(response => response.json())
    .then(result => {
      dataLimit.value = result.total;
      data.value = data.value.concat(result.posts);
    })
    .catch(console.error)
    .finally(() => { isLoading.value = false; });
}

function onAfterRender(start: number, end: number) {
  console.log('onAfterItemsRendered', start, end);
  if (end === data.value.length - 1 && end < dataLimit.value - 1 && !isLoading.value) {
    console.log('will try to fetch data');
    loadMore(10, data.value.length);
  }
  startIndex.value = start;
  endIndex.value = end;
}
</script>

<template>
  <h4>Try resizing the container and scroll.</h4>
  <div>
    Rendered indices {{ startIndex }} - {{ endIndex }}, total {{ total }}.
    Loaded {{ data.length }} of {{ dataLimit }}.
  </div>
  <layout-virtual :overscan-height="200" :data="listData" v-bind="styling" @after-items-rendered="onAfterRender">
    <template #renderItem="{ data: itemData, index }">
      <div v-if="itemData === SHOW_LOADER" class="loader-container" :data-index="index">
        <div class="loader" />
      </div>
      <div v-else class="article-card" :data-index="index">
        <span class="ac-index">{{ index }}</span>
        <h3 class="ac-title">{{ itemData.title }}</h3>
        <p class="ac-body">{{ itemData.body }}</p>
        <button class="ac-button">Learn more</button>
      </div>
    </template>
  </layout-virtual>
</template>

Angular

import '@angular/compiler';
import { CommonModule } from '@angular/common';
import { Component, signal, computed } from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';
import LayoutVirtual from 'angular-layout-virtual';

const SHOW_LOADER = Symbol('show-loader');
type Post = { title: string; body: string } | typeof SHOW_LOADER;

@Component({
  selector: '#app',
  standalone: true,
  imports: [CommonModule, LayoutVirtual],
  template: `
    <h4>Try resizing the container and scroll.</h4>
    <div>
      Rendered indices {{ startIndex() }} - {{ endIndex() }}, total {{ total() }}.
      Loaded {{ data().length }} of {{ dataLimit() }}.
    </div>
    <layout-virtual
      [overscanHeight]="200"
      [data]="listData()"
      scrollerClass="lv-scroller"
      viewportClass="lv-viewport"
      contentLayerClass="lv-content-layer"
      (afterItemsRendered)="onAfterRender(...$event)">
        <ng-template #renderItem let-itemData="data" let-index="index">
          @if (itemData===loader) {
            <div class="loader-container" [attr.data-index]="index">
              <div class="loader"></div>
            </div>
          } @else {
            <div class="article-card" [attr.data-index]="index">
              <span class="ac-index">{{ index }}</span>
              <h3 class="ac-title">{{ itemData.title }}</h3>
              <p class="ac-body">{{ itemData.body }}</p>
              <button class="ac-button">Learn more</button>
            </div>
          }
        </ng-template>
    </layout-virtual>
  `,
})
export default class InfiniteScrollExample {
  private loadedUrls = new Set<string>();

  data = signal<Post[]>([]);
  dataLimit = signal(100);
  isLoading = signal(false);
  loader = SHOW_LOADER;

  listData = computed(() =>
    this.isLoading() ? this.data().concat(SHOW_LOADER) : this.data()
  );

  startIndex = signal(0);
  endIndex = signal(0);
  total = computed(() => this.endIndex() - this.startIndex() + 1);

  onAfterRender(start: number, end: number, _:  unknown) {
    console.log('onAfterItemsRendered', start, end);
    if (end === this.data().length - 1 && end < this.dataLimit() - 1 && !this.isLoading()) {
      console.log('will try to fetch data');
      this.loadMore(10, this.data().length);
    }
    this.startIndex.set(start);
    this.endIndex.set(end);
  }

  private loadMore(limit: number, skip: number) {
    const apiUrl = `https://dummyjson.com/posts?limit=${limit}&skip=${skip}&select=title,body`;
    if (this.loadedUrls.has(apiUrl)) return;

    this.isLoading.set(true);
    this.loadedUrls.add(apiUrl);
    console.log('will fetch', apiUrl);

    fetch(apiUrl)
      .then(result => result.json())
      .then(result => {
        this.dataLimit.set(result.total);
        this.data.update(posts => posts.concat(result.posts));
      })
      .catch(console.error)
      .finally(() => this.isLoading.set(false));
  }
}

bootstrapApplication(InfiniteScrollExample).catch((error: unknown) => {
  console.error(error);
});

Scroll to the bottom to trigger the next page of posts, which are fetched from a remote API and appended seamlessly to the existing list. A spinner item appears at the end while the request is in flight, then is replaced by the incoming posts.

No total item count needs to be known upfront.

The sentinel pattern drives pagination: when the last real item scrolls into the rendered range, the next fetch is triggered automatically.

You can also resize the scrolling container — the items adapt to the available width just as they would in a normal document flow.