Infinite Scroll Up (Reverse Infinite Scroll)
Example code
Styling
body {
--darker-bg-color: rgb(71, 81, 91);
--darker-bg-color-odd: rgb(82, 94, 105);
--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;
}
.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 }
}
.message-card {
width: 80%;
position: relative;
display: flex;
gap: 16px;
flex-direction: column;
overflow: hidden;
border-radius: 8px;
background-color: var(--darker-bg-color);
padding: 12px;
}
[id$="0"],
[id$="2"],
[id$="4"],
[id$="6"],
[id$="8"] {
background-color: var(--darker-bg-color);
justify-self: left;
}
[id$="1"],
[id$="3"],
[id$="5"],
[id$="7"],
[id$="9"] {
background-color: var(--darker-bg-color-odd);
justify-self: right;
}
.mc-id {
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;
}
.mc-author {
font-size: clamp(14px, 2.6cqw, 20px);
font-weight: 600;
line-height: 1.3;
max-width: 85%;
}
.mc-quote {
font-size: clamp(12px, 2cqw, 15px);
line-height: 1.5;
opacity: 0.8;
}
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');
const LOAD_NUMBER = 20;
type Message = { quote: string; author: string; id: string } | typeof SHOW_LOADER;
const loadedUrls = new Set<string>();
let data: Message[] = [];
let dataLimit = 100;
let isLoading = false;
let scrollerReady = false;
let scroller: HTMLElement | null = null;
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 MessageCard({ data: item, index }: ListItemProps<Message>): HTMLElement {
if (item === SHOW_LOADER) return Loader(index);
const { quote, author, id } = item as { quote: string; author: string; id: string };
const card = document.createElement('div');
const idEl = document.createElement('span');
const authorEl = document.createElement('p');
const quoteEl = document.createElement('p');
card.id = id;
card.className = 'message-card';
card.dataset.index = index.toString();
idEl.className = 'mc-id';
idEl.textContent = id;
authorEl.className = 'mc-author';
authorEl.textContent = author;
quoteEl.className = 'mc-quote';
quoteEl.textContent = quote;
card.append(idEl, authorEl, quoteEl);
return card;
}
function onAfterRender(startIndex: number, endIndex: number) {
const total = endIndex - startIndex + 1;
stats.textContent = `Rendered indices ${startIndex} - ${endIndex}, total ${total}. Loaded ${data.length} of ${dataLimit}.`;
if (startIndex === 0 && data.length < dataLimit && !isLoading && scrollerReady) {
loadMore(LOAD_NUMBER, data.length);
}
}
function loadMore(limit: number, skip: number) {
const apiUrl = `https://dummyjson.com/quotes?limit=${limit}&skip=${skip}`;
if (loadedUrls.has(apiUrl)) return;
isLoading = true;
data = ([SHOW_LOADER] as Message[]).concat(data);
api.setData(data, { index: 0, insertCount: 1 });
loadedUrls.add(apiUrl);
fetch(apiUrl)
.then(response => response.json())
.then(result => (dataLimit = result.total, result.quotes.reverse()))
.then(quotes => {
const rest = data[0] === SHOW_LOADER ? data.slice(1) : data;
data = quotes.concat(rest);
if (data.length === LOAD_NUMBER) {
setTimeout(() => {
if (scroller) scroller.scroll({ top: scroller.scrollHeight });
scrollerReady = true;
}, 32);
}
return quotes;
})
.then(quotes => api.setData(data, { index: 0, insertCount: quotes.length - 1 }))
.catch(console.error)
.finally(() => { isLoading = false; });
}
const title = document.createElement('h4');
title.textContent = 'Try resizing the container and scroll.';
const stats = document.createElement('div');
const { container, api } = LayoutVirtual<Message>({
data,
renderItem: MessageCard,
...styling,
onAfterItemsRendered: onAfterRender,
});
const app = document.getElementById('app')!;
app.append(title, stats, container);
scroller = document.querySelector<HTMLElement>('[data-lv-scroller]');
loadMore(LOAD_NUMBER, 0);React
import { useCallback, useState, useRef, useEffect } from 'react';
import LayoutVirtual, { type VirtualizedListReactClasses, type ListItemProps } from 'react-layout-virtual';
import type { DataMutation } from 'layout-virtual/types';
const styling: VirtualizedListReactClasses = {
scrollerClass: 'lv-scroller',
viewportClass: 'lv-viewport',
contentLayerClass: 'lv-content-layer',
};
const SHOW_LOADER = Symbol('show-loader');
const LOAD_NUMBER = 20;
type Message = { quote: string; author: string; id: string; } | typeof SHOW_LOADER;
function Loader({ index }: Omit<ListItemProps<Message>, 'data'>) {
return <div className='loader-container' data-index={index}><div className='loader'></div></div>;
}
function MessageCard({ data, index }: ListItemProps<Message>) {
if (data === SHOW_LOADER) {
return <Loader index={index} />;
}
const { quote, author, id } = data;
return (
<div id={id} className={'message-card'} data-index={index}>
<span className='mc-id'>{id}</span>
<p className={'mc-author'}>{author}</p>
<p className={'mc-quote'}>{quote}</p>
</div>
);
}
const InfiniteScrollUpExample = () => {
const loadedUrls = useRef(new Set());
const scroller = useRef<HTMLElement | null>(null);
const scrollerReady = useRef<boolean>(false);
const [mutation, setMutation] = useState<DataMutation>({ index: 0, insertCount: 0 });
const [ data, setData ] = useState<Message[]>([]);
const [ dataLimit, setDataLimit ] = useState(100);
const [ isLoading, setIsLoading ] = useState(false);
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/quotes?limit=${limit}&skip=${skip}`;
if (loadedUrls.current.has(apiUrl)) return;
setIsLoading(true);
setData(prevData => ([SHOW_LOADER] as Message[]).concat(prevData));
setMutation({ index: 0, insertCount: 1 });
loadedUrls.current.add(apiUrl);
fetch(apiUrl)
.then(response => response.json())
.then(result => (setDataLimit(result.total), result.quotes.reverse()))
.then(quotes => (setData(prevQuotes => quotes.concat(prevQuotes[0] === SHOW_LOADER ? prevQuotes.slice(1) : prevQuotes)), quotes))
.then(quotes => setMutation({ index: 0, insertCount: quotes.length - 1 }))
.catch(console.error)
.finally(() => setIsLoading(false));
}, []);
const onAfterRender = useCallback((startIndex: number, endIndex: number) => {
if (startIndex === 0 && data.length < dataLimit && !isLoading && scrollerReady.current) {
loadMore(LOAD_NUMBER, data.length);
}
setRenderedIndices({ startIndex, endIndex });
}, [data, dataLimit, loadMore, isLoading]);
useEffect(() => {
scroller.current = document.querySelector('[data-lv-scroller]');
loadMore(LOAD_NUMBER, 0);
}, []);
useEffect(() => {
if (data.length === LOAD_NUMBER) {
setTimeout(() => {
scroller.current?.scroll({ top: scroller.current.scrollHeight });
scrollerReady.current = true;
}, 32);
}
}, [data]);
return (
<>
<h4>Try resizing the container and scroll.</h4>
<div>Rendered indices {startIndex} - {endIndex}, total {total}. Loaded {data.length} of {dataLimit}.</div>
<LayoutVirtual data={data} renderItem={MessageCard} {...styling} onAfterItemsRendered={onAfterRender} mutation={mutation} />
</>
);
};
export default InfiniteScrollUpExample;Vue
<script setup lang="ts">
import { ref, computed, shallowRef, watch, onMounted } from 'vue';
import LayoutVirtual from 'vue-layout-virtual';
import type { VirtualizedListVueClasses } from 'vue-layout-virtual';
import type { DataMutation } from 'layout-virtual/types';
const styling: VirtualizedListVueClasses = {
scrollerClass: 'lv-scroller',
viewportClass: 'lv-viewport',
contentLayerClass: 'lv-content-layer',
};
const SHOW_LOADER = Symbol('show-loader');
const LOAD_NUMBER = 20;
type Message = { quote: string; author: string; id: string } | typeof SHOW_LOADER;
let isLoading = false;
let scroller: HTMLElement | null = null;
let scrollerReady = false;
const loadedUrls = new Set<string>();
const mutation = ref<DataMutation>({ index: 0, insertCount: 0 });
const data = shallowRef<Message[]>([]);
const dataLimit = ref(100);
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/quotes?limit=${limit}&skip=${skip}`;
if (loadedUrls.has(apiUrl)) return;
isLoading = true;
data.value = ([SHOW_LOADER] as Message[]).concat(data.value);
mutation.value = { index: 0, insertCount: 1 };
loadedUrls.add(apiUrl);
fetch(apiUrl)
.then(response => response.json())
.then(result => (dataLimit.value = result.total, result.quotes.reverse()))
.then(quotes => {
data.value = quotes.concat(data.value[0] === SHOW_LOADER ? data.value.slice(1) : data.value);
mutation.value = { index: 0, insertCount: quotes.length - 1 };
})
.catch(console.error)
.finally(() => { isLoading = false; });
}
function onAfterRender(start: number, end: number) {
if (start === 0 && data.value.length < dataLimit.value && !isLoading && scrollerReady) {
loadMore(LOAD_NUMBER, data.value.length);
}
startIndex.value = start;
endIndex.value = end;
}
onMounted(() => {
scroller = document.querySelector('[data-lv-scroller]');
loadMore(LOAD_NUMBER, 0);
});
watch(data, () => {
if (data.value.length === LOAD_NUMBER) {
setTimeout(() => {
scroller?.scroll({ top: scroller.scrollHeight });
scrollerReady = true;
}, 32);
}
});
</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 :data="data" v-bind="styling" :mutation="mutation" @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 :id="itemData.id" class="message-card" :data-index="index">
<span class="mc-id">{{ itemData.id }}</span>
<p class="mc-author">{{ itemData.author }}</p>
<p class="mc-quote">{{ itemData.quote }}</p>
</div>
</template>
</layout-virtual>
</template>Angular
import '@angular/compiler';
import { CommonModule } from '@angular/common';
import { Component, signal, computed, effect, type AfterViewInit } from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';
import LayoutVirtual from 'angular-layout-virtual';
import type { DataMutation } from 'layout-virtual/types';
const SHOW_LOADER = Symbol('show-loader');
const LOAD_NUMBER = 20;
type Message = { quote: string; author: string; id: 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
[data]="data()"
[mutation]="mutation()"
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 [id]="itemData.id" class="message-card" [attr.data-index]="index">
<span class="mc-id">{{ itemData.id }}</span>
<p class="mc-author">{{ itemData.author }}</p>
<p class="mc-quote">{{ itemData.quote }}</p>
</div>
}
</ng-template>
</layout-virtual>
`,
})
export default class InfiniteScrollUpExample implements AfterViewInit {
private loadedUrls = new Set<string>();
private scroller: HTMLElement | null = null;
private scrollerReady = false;
loader = SHOW_LOADER;
mutation = signal<DataMutation>({ index: 0, insertCount: 0 });
data = signal<Message[]>([]);
dataLimit = signal(100);
isLoading = false;
startIndex = signal(0);
endIndex = signal(0);
total = computed(() => this.endIndex() - this.startIndex() + 1);
constructor() {
effect(() => {
if (this.data().length === LOAD_NUMBER) {
setTimeout(() => {
this.scroller?.scroll({ top: this.scroller!.scrollHeight });
this.scrollerReady = true;
}, 32);
}
});
}
ngAfterViewInit() {
this.scroller = document.querySelector('[data-lv-scroller]');
this.loadMore(LOAD_NUMBER, 0);
}
onAfterRender(start: number, end: number, _: unknown) {
if (start === 0 && this.data().length < this.dataLimit() && !this.isLoading && this.scrollerReady) {
this.loadMore(LOAD_NUMBER, this.data().length);
}
this.startIndex.set(start);
this.endIndex.set(end);
}
private loadMore(limit: number, skip: number) {
const apiUrl = `https://dummyjson.com/quotes?limit=${limit}&skip=${skip}`;
if (this.loadedUrls.has(apiUrl)) return;
this.isLoading = true;
this.data.update(prev => ([SHOW_LOADER] as Message[]).concat(prev));
this.mutation.set({ index: 0, insertCount: 1 });
this.loadedUrls.add(apiUrl);
fetch(apiUrl)
.then(result => result.json())
.then(result => (this.dataLimit.set(result.total), result.quotes.reverse()))
.then(quotes => {
this.data.update(prev => quotes.concat(prev[0] === SHOW_LOADER ? prev.slice(1) : prev));
this.mutation.set({ index: 0, insertCount: quotes.length - 1 });
})
.catch(console.error)
.finally(() => this.isLoading = false);
}
}
bootstrapApplication(InfiniteScrollUpExample).catch((error: unknown) => {
console.error(error);
});This example demonstrates loading older content by scrolling up, the pattern used by chat apps, message threads, and activity feeds where the newest items sit at the bottom and history extends upward.
Unlike a standard infinite list — which appends new items to the end as the user scrolls down — this example:
- Starts scrolled to the bottom. On mount, it loads the first batch of messages, then programmatically scrolls the viewport to the bottom so the most recent message is in view, just like opening a chat.
- Prepends items when the user scrolls to the top. As soon as the topmost rendered index reaches 0, it fetches the next page of older messages and inserts them above the current content.
- Preserves scroll position during prepend. Inserting items at the top of a scrollable list normally causes a visible jump, since the browser doesn’t know to compensate for content added above the viewport. This example passes a
mutationdescriptor ({ index, insertCount }) alongside the updated data so the underlying engine can adjust scroll offset precisely, keeping the content the user was looking at fixed in place. - Shows a loading indicator at the point of insertion. While older messages are being fetched, a loader placeholder is inserted at the top of the list (index 0) and removed once the real data arrives.
- Guards against premature loading. A
scrollerReadyflag ensures the “load more on scroll-to-top” behavior only activates after the initial scroll-to-bottom has completed, preventing an unwanted fetch loop on first render before the user has actually scrolled.
What to look for: open the demo, notice it opens already scrolled to the latest message, then scroll upward — older messages load in above with no scroll jump, and the loader briefly appears at the top edge while the next page is being fetched.