Chat Interface
Chat with history loading and auto-scroll
A chat interface demonstration with 50 messages. Features dynamic item heights, initial scroll to bottom, scroll restoration when loading history (scrolling up), smooth scrolling for new messages, and sticky footer for the input block.
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt
11:44:31 PM
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididun
11:45:44 PM
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor
11:46:00 PM
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore
11:47:06 PM
Lorem ipsum dolor sit amet, con
11:48:34 PM
Lorem ipsum dolor sit amet, consectetur
11:49:04 PM
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod t
11:50:33 PM
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod
11:52:06 PM
Lorem ipsum dolor sit amet, cons
11:53:22 PM
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed
11:54:34 PM
<script setup lang="ts">
import type { ScrollDetails } from '@pdanpdan/virtual-scroll';
import type { Ref } from 'vue';
import { VirtualScroll } from '@pdanpdan/virtual-scroll';
import { computed, inject, nextTick, onMounted, onUnmounted, ref } from 'vue';
import ExampleContainer from '#/components/ExampleContainer.vue';
import ScrollStatus from '#/components/ScrollStatus.vue';
import { createSeededRandom } from '#/lib/random';
import { useExampleScroll } from '#/lib/useExampleScroll';
import { html as highlightedCode } from './+Page.vue?highlight';
interface Message {
id: number;
text: string;
isMe: boolean;
time: string;
}
const items = ref<Message[]>([]);
const {
virtualScrollRef,
scrollDetails,
} = useExampleScroll();
const debugMode = inject<Ref<boolean>>('debugMode', ref(false));
const isLoading = ref(false);
const isAtBottom = ref(true);
const hasNewMessages = ref(false);
const ssrRange = computed(() => ({
start: Math.max(0, items.value.length - 10),
end: items.value.length,
}));
const initialScrollIndex = computed(() => items.value.length - 1);
const LOREM = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.';
const random = createSeededRandom(12345);
let timeMock = (new Date('2026-01-10T21:12:23Z')).valueOf();
function getTimeMock() {
timeMock += Math.floor(random() * 100000) + 3;
return new Date(timeMock).toLocaleTimeString('en-US', {
timeZone: 'Europe/Bucharest',
});
}
function generateMessage(id: number, sentByMe?: boolean): Message {
const isMe = sentByMe ?? random() > 0.5;
const length = Math.floor(random() * 100) + 10;
return {
id,
text: LOREM.slice(0, length),
isMe,
time: getTimeMock(),
};
}
function loadMessages(count: number, prepend = false) {
const newItems = [];
const startId = prepend ? (items.value[ 0 ]?.id || 0) - count : (items.value[ items.value.length - 1 ]?.id || 0) + 1;
for (let i = 0; i < count; i++) {
const id = startId + i;
newItems.push(generateMessage(id));
}
if (prepend) {
items.value = [ ...newItems, ...items.value ];
} else {
items.value = [ ...items.value, ...newItems ];
}
}
// Initial load
loadMessages(50);
// Auto-generate messages
let generateMessagesTimer: ReturnType<typeof setTimeout> | null = null;
onMounted(() => {
const fn = () => {
addMessage(LOREM.slice(0, Math.floor(random() * 100) + 10), false);
generateMessagesTimer = setTimeout(fn, 5000 + Math.random() * 20000);
};
generateMessagesTimer = setTimeout(fn, 5000 + Math.random() * 20000);
});
onUnmounted(() => {
if (generateMessagesTimer != null) {
clearTimeout(generateMessagesTimer);
generateMessagesTimer = null;
}
});
function onScroll(details: ScrollDetails) {
scrollDetails.value = details;
const bottomThreshold = 20;
const remaining = details.totalSize.height - (details.scrollOffset.y + details.viewportSize.height);
isAtBottom.value = remaining < bottomThreshold;
if (isAtBottom.value) {
hasNewMessages.value = false;
}
// Infinite scroll upwards (history)
if (details.scrollOffset.y < 100 && !isLoading.value && !details.isProgrammaticScroll && items.value.length > 0 && items.value.length < 500) {
isLoading.value = true;
setTimeout(() => {
loadMessages(20, true);
// Wait for VirtualScroll to restores scroll
// Additional small delay to ensure all measurements and corrections are done
setTimeout(() => {
isLoading.value = false;
}, 50);
}, 500);
}
}
const newMessage = ref('');
function addMessage(text: string, isMe: boolean) {
const id = (items.value[ items.value.length - 1 ]?.id || 0) + 1;
const wasAtBottom = isAtBottom.value;
items.value = [
...items.value,
{
id,
text,
isMe,
time: (new Date()).toLocaleTimeString('en-US', {
timeZone: 'Europe/Bucharest',
}),
},
];
if (wasAtBottom || isMe) {
nextTick(() => {
virtualScrollRef.value?.scrollToIndex(items.value.length - 1, 0, { align: 'end', behavior: 'smooth' });
});
} else if (!isMe) {
hasNewMessages.value = true;
}
}
function sendMessage() {
if (!newMessage.value.trim()) {
return;
}
const text = newMessage.value;
newMessage.value = '';
addMessage(text, true);
// Response with random delay
const delay = 500 + Math.random() * 2000;
setTimeout(() => {
addMessage(`Response to: "${ text.slice(0, 20) }..."`, false);
}, delay);
}
function scrollToBottom() {
virtualScrollRef.value?.scrollToIndex(items.value.length - 1, 0, { align: 'end', behavior: 'smooth' });
}
</script>
<template>
<ExampleContainer :code="highlightedCode">
<template #title>
<span class="example-title example-title--group-1">Chat Interface</span>
</template>
<template #description>
A chat interface demonstration with {{ items.length.toLocaleString() }} messages. Features <strong>dynamic item heights</strong>, <strong>initial scroll to bottom</strong>, <strong>scroll restoration</strong> when loading history (scrolling up), <strong>smooth scrolling</strong> for new messages, and <strong>sticky footer</strong> for the input block.
</template>
<template #icon>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="example-icon example-icon--group-1"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M8.625 9.75a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H8.25m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H12m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 0 1-2.555-.337A5.972 5.972 0 0 1 5.41 20.97a5.969 5.969 0 0 1-.474-.065 4.48 4.48 0 0 0 .978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.152 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25Z" />
</svg>
</template>
<template #subtitle>
Chat with history loading and auto-scroll
</template>
<template #controls>
<ScrollStatus :scroll-details="scrollDetails" />
</template>
<div class="example-container flex flex-col overflow-auto">
<div v-if="isLoading" class="absolute top-2 inset-x-0 flex justify-center z-10">
<span class="loading loading-spinner loading-sm text-primary" />
</div>
<div
v-if="hasNewMessages && !isAtBottom"
class="absolute bottom-20 inset-x-0 flex justify-center z-10 px-4"
>
<button
class="btn btn-primary btn-sm @4xl:btn-md shadow-strong shadow-primary/40 gap-2 rounded-full border-2 border-white/10"
@click="scrollToBottom"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2.5"
stroke="currentColor"
class="size-4 mt-1 animate-bounce"
>
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 5.25 7.5 7.5 7.5-7.5m-15 6 7.5 7.5 7.5-7.5" />
</svg>
<span class="font-black text-xs small-caps tracking-tight">New messages</span>
</button>
</div>
<VirtualScroll
ref="virtualScrollRef"
class="flex-1"
:debug="debugMode"
:items="items"
:restore-scroll-on-prepend="true"
:ssr-range="ssrRange"
:initial-scroll-index="initialScrollIndex"
initial-scroll-align="end"
:scroll-padding-start="10"
:scroll-padding-end="10"
:gap="12"
:sticky-footer="true"
aria-label="Chat messages"
@scroll="onScroll"
>
<template #item="{ item }">
<div class="chat px-4" :class="item.isMe ? 'chat-end' : 'chat-start'">
<div class="chat-bubble text-sm shadow-sm" :class="item.isMe ? 'chat-bubble-primary' : ''">
{{ item.text }}
</div>
<div class="chat-footer opacity-60 mt-1">{{ item.time }}</div>
</div>
</template>
<template #footer>
<div class="p-3 @4xl:p-4 bg-base-200 border-t border-base-300 flex gap-2">
<input
v-model="newMessage"
type="text"
placeholder="Type a message..."
class="input input-bordered input-sm @4xl:input-md w-full"
aria-label="Message"
@keydown.enter="sendMessage"
/>
<button class="btn btn-primary btn-sm @4xl:btn-md px-6" @click="sendMessage">Send</button>
</div>
</template>
</VirtualScroll>
</div>
</ExampleContainer>
</template>
- Scroll Status
- Directionvertical
- Current Item #-
- Rendered Range #0:0
- Total Size (px)0w ×0h
- Viewport Size (px)0w ×0h
- Scroll Offset (px)0x ×0y