Skip to content

Display

Virtual List

A windowed list with load-older at the top that keeps the scroll position, for long chat histories.

<script>
  import { VirtualList } from "@codeatlasdev/ui";
</script>

<VirtualList {items} getKey={(m) => m.id} label="Conversation" {hasMore} loadingMore={loading} onloadmore={loadOlder}>
  {#snippet item(m)}<Message {m} />{/snippet}
</VirtualList>

Only the rows in view (plus overscan) are in the DOM; row heights are measured, so they can vary. Prepending older rows keeps the row you were reading in the same place: the list finds the previous first row by getKey and corrects scrollTop. LiveList follows the bottom but cannot prepend history without a jump; this one can, and also follows new rows while you are at the bottom. A visible “Load older messages” button gives keyboard and screen reader users the same trigger as scrolling.

#254

Chat history

Scroll to the top to load older messages; the view does not jump.

  • Message 5000
  • Message 5001
  • Message 5002
  • Message 5003
  • Message 5004
  • Message 5005
  • Message 5006
  • Message 5007
  • Message 5008
  • Message 5009
  • Message 5010
  • Message 5011
  • Message 5012
  • Message 5013
  • Message 5014
  • Message 5015
  • Message 5016

Reference #254 · Pick in the gallery

Code
svelte
<script lang="ts">
  import { Button, Stack, VirtualList } from '@codeatlasdev/ui';

  type Msg = { id: number; text: string };
  let oldest = 5000;
  let items = $state<Msg[]>(Array.from({ length: 40 }, (_, i) => ({ id: oldest + i, text: `Message ${oldest + i}` })));
  let loading = $state(false);
  let hasMore = $state(true);

  async function loadOlder() {
    loading = true;
    await new Promise((r) => setTimeout(r, 400));
    const page = Array.from({ length: 30 }, (_, i) => ({ id: oldest - 30 + i, text: `Message ${oldest - 30 + i}` }));
    oldest -= 30;
    items = [...page, ...items];
    hasMore = oldest > 4850;
    loading = false;
  }
  const add = () => items.push({ id: items.at(-1)!.id + 1, text: `Message ${items.at(-1)!.id + 1}` });
</script>

<Stack gap="3">
  <VirtualList {items} getKey={(m) => m.id} label="Conversation" height="18rem" {hasMore} loadingMore={loading} onloadmore={loadOlder}>
    {#snippet item(m)}<div class="row">{m.text}</div>{/snippet}
  </VirtualList>
  <Button size="sm" variant="outlined" onclick={add}>Receive a message</Button>
</Stack>

<style>
  .row { padding: var(--ca-space-2) var(--ca-space-3); }
</style>

Props

PropTypeDefaultDescription
itemsreadonly T[]–Rows, oldest first.
itemSnippet<[T, number]>–One row, rendered inside an li.
getKey(item: T, index: number) => string | number–Stable key per row; required to keep position on prepend.
labelstring–Accessible name of the scrolling region.
estimateSizenumber56Row height guess in px until measured.
overscannumber6Extra rows rendered above and below the view.
heightstring'24rem'Height of the scrolling area.
followbooleantrueStay at the bottom while new rows arrive and you are there.
hasMorebooleanfalseMore older rows exist.
loadingMorebooleanfalseA page is loading.
loadThresholdnumber120Distance from the top in px that triggers onloadmore.
onloadmore() => void | Promise<void>–Load the next older page.
emptySnippet–Shown when there are no rows.