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.

How to build a feature like this

A chat is a vertical list that grows at the bottom with rows of uneven height - one of the harder cases for a virtualized list to auto-scroll well. Two behaviors matter: a newly appended message scrolls into view so the list "sticks" to the newest message, and when the user has scrolled up to read history the viewport is never yanked away from them. The mechanism is a single @scroll handler that derives "am I at the bottom?" from the emitted ScrollDetails, plus a programmatic end-anchored scrollToIndex() after each append; loading older messages relies on the engine's prepend restoration so the line being read stays put. The main tradeoff: because bubbles have measured (dynamic) heights, rows must mount and be measured, and an end-anchored scroll keeps re-clamping until those measurements settle.

1. Size the host and choose a sizing mode

VirtualScroll renders its own scrollable host, and virtualization needs a known viewport: if the host is not height-constrained it grows with its content and never scrolls. Give it a definite height, or flex/grid space with min-height: 0 so the box can shrink below its content and actually scroll.

Fixed-height or variable-height rows is the first sizing decision. If every row has the same height, pass a numeric item-size and the engine derives the whole layout arithmetically with no DOM measurement. Chat bubbles wrap to different heights, so instead leave item-size unset: that puts the list in dynamic mode, where each mounted row is measured with a ResizeObserver and its measured size drives layout. Dynamic rows need real data - items is an array of message objects read from the #item slot's item prop, because there is no height oracle an index-only array could fall back on. While a row is unmeasured the engine lays it out at default-item-size (default 40); set it near the average rendered row so the scrollbar, total height, and far scrollToIndex targets stay accurate until the measurements arrive. Keep each bubble sized to its content.

The examples also draw the built-in virtual scrollbar (boolean virtual-scrollbar) on the list. Besides consistent cross-browser styling it is a performance improvement: the overlay bar is driven by the engine's own scroll math, so its rendering cost stays flat no matter how long the list grows.

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

const chatScroll = ref();
const messages = ref([{ id: 1, text: 'Hello', isMe: true }]);
</script>

<template>
  <VirtualScroll
    virtual-scrollbar
    ref="chatScroll"
    class="chat-list"
    :items="messages"
    :default-item-size="64"
    @scroll="onScroll"
  >
    <template #item="{ item }">
      <div class="chat" :class="item.isMe ? 'chat--me' : 'chat--other'">{{ item.text }}</div>
    </template>
  </VirtualScroll>
</template>

<style scoped>
/* The scroll host needs a definite height; min-h-0 lets it shrink inside a
   column flex parent so the list can actually scroll. */
.chat-list {
  height: 480px;
}
.chat {
  padding: 8px 12px;
  margin-block: 4px;
  border-radius: 12px;
}
.chat--me {
  text-align: right;
}
</style>

2. Open at the newest message

To start showing the tail rather than the empty top, point initial-scroll-index at the last row and pair it with initial-scroll-align="end" so that row is pinned to the bottom edge on mount. Pin the last index rather than guessing a pixel offset: the engine re-clamps an end-anchored target while dynamic measurements settle, so even on variable-height rows the first frame corrects itself flush against the real end.

3. Stick to the bottom only while the user is there

Two intents are in tension: when the user is at the newest message an incoming append should scroll the list down to reveal it, but when the user has scrolled up to read history the same append must not move the viewport. Resolve this in one @scroll handler that keeps a reactive "at the bottom?" flag.

The geometry is plain arithmetic over ScrollDetails: totalSize.height is the full content height, scrollOffset.y is where the viewport top sits, and adding viewportSize.height locates the viewport bottom - so totalSize.height − (scrollOffset.y + viewportSize.height) is the distance remaining to the content end (virtual units, which equal rendered pixels once rows are measured). Compare it to a small threshold of a few tens of pixels that absorbs rounding and the scrollbar, so "at the bottom" is forgiving.

Read that flag before mutating items. If the user was at the bottom - or the append is their own action, which should always be revealed - scroll after the new row mounts: call scrollToIndex(items.length − 1, 0, { align: 'end', behavior: 'smooth' }) inside nextTick. End alignment is the right target even though the new row's height is not measured yet, because an end-anchored scroll keeps re-clamping until the settling measurements define the true end. If the user was not at the bottom and the row is incoming, leave the viewport untouched and surface a "jump to newest" affordance instead - never steal the reader's position.

Choose the scroll behavior to match the traffic: 'smooth' animates a gentle follow but can look laggy when several rows land in a burst, while 'auto' snaps instantly and stays responsive under load.

import type { ScrollDetails } from '@pdanpdan/virtual-scroll';

import { nextTick, ref } from 'vue';

const isAtBottom = ref(true);
const hasNewMessages = ref(false);

// Distance from the bottom edge of the viewport to the content end, in virtual
// units (VU). With dynamic heights one VU equals one rendered pixel, so the
// math is the same as on a plain scroll container.
function onScroll(d: ScrollDetails) {
  const remaining =
    d.totalSize.height - (d.scrollOffset.y + d.viewportSize.height);
  isAtBottom.value = remaining < 20; // within 20px of the newest message
  if (isAtBottom.value) hasNewMessages.value = false;
}

function append(msg: { id: number; text: string; isMe: boolean }) {
  const wasAtBottom = isAtBottom.value; // read BEFORE mutating the list
  messages.value = [...messages.value, msg];

  if (wasAtBottom || msg.isMe) {
    // Let the new row mount, then pin the last message to the bottom edge.
    // align: 'end' keeps re-clamping while dynamic measurements settle, so the
    // first jump lands flush even though the row's real height is unknown yet.
    nextTick(() => {
      chatScroll.value?.scrollToIndex(messages.value.length - 1, 0, {
        align: 'end',
        behavior: 'smooth',
      });
    });
  } else {
    // The user scrolled up to read history: never yank the viewport. Surface a
    // "New messages" button (hasNewMessages) that jumps on click instead.
    hasNewMessages.value = true;
  }
}

4. Prepend history without losing your place

Loading older messages prepends to the top of the list, which otherwise lets the browser's scroll anchor drift and yanks the user away from the line they were reading. The restore-scroll-on-prepend prop (default false) makes the engine hold the first visible row at the same screen offset across the prepend - add it whenever your list can grow at the start (paged-up history, infinite scroll upward).

Trigger the load from the same @scroll handler, guarded so it cannot fire redundantly: only when the viewport is near the top (scrollOffset.y below a small threshold), the scroll was user-driven (the isProgrammaticScroll flag in ScrollDetails is false), no load is already running, and more history remains. Unshift the older batch and replace items wholesale (a fresh array) so the engine re-initializes against the new length while the restoration prop keeps the anchor stable.

import { ref } from 'vue';

const hasMoreHistory = ref(true);
const isLoading = ref(false);

// Called from the scroll handler while the user is near the top (< 100px),
// not mid-programmatic scroll, and not already loading.
function loadOlder() {
  if (isLoading.value || !hasMoreHistory.value) return;
  isLoading.value = true;

  // Simulated fetch: the new batch is PREPENDED (older messages go first).
  setTimeout(() => {
    const older = Array.from({ length: 20 }, (_, i) => ({
      id: messages.value[0].id - 20 + i,
      text: `older #${i}`,
      isMe: false,
    }));
    messages.value = [...older, ...messages.value];
    hasMoreHistory.value = messages.value.length < 10_000;
    isLoading.value = false;
  }, 500);
}
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididun
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore
Lorem ipsum dolor sit amet, con
Lorem ipsum dolor sit amet, consectetur
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod t
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod
Lorem ipsum dolor sit amet, cons
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed
  • Scroll Status
  • Direction
    vertical
  • Current Item #
    -
  • Rendered Range #
    0:0
  • Total Size (px)
    0w ×0h
  • Viewport Size (px)
    0w ×0h
  • Scroll Offset (px)
    0x ×0y