Skip to content

Instantly share code, notes, and snippets.

@Lure5134
Last active June 23, 2025 07:00
Show Gist options
  • Save Lure5134/38001e338c95c830fb4725330f4ef048 to your computer and use it in GitHub Desktop.
Save Lure5134/38001e338c95c830fb4725330f4ef048 to your computer and use it in GitHub Desktop.
A virtual list for svelte 5.
<script lang="ts" generics="T">
import { onMount, tick, type Snippet } from "svelte";
const {
items,
height,
itemHeight,
children,
getKey,
}: {
items: Array<T>;
height: string;
itemHeight?: number | undefined;
children: Snippet<[T]>;
getKey?: (item: T) => string | number;
} = $props();
// read-only, but visible to consumers via bind:start
let start = $state(0);
let end = $state(0);
// local state
let height_map: Array<number> = $state([]);
let rows: HTMLCollectionOf<Element> = $state(null!);
let viewport: HTMLElement = $state(null!);
let contents: HTMLElement = $state(null!);
let viewport_height = $state(0);
let mounted: boolean = $state(false);
let resizeObserver: ResizeObserver | null = null;
let top = $state(0);
let bottom = $state(0);
let average_height: number = $state(null!);
const visible: Array<{ id: number | string; data: T }> = $derived(
items.slice(start, end).map((data, i) => {
return { id: getKey?.(data) ?? i + start, data };
}),
);
// whenever `items` changes, invalidate the current heightmap
$effect(() => {
if (mounted) {
refresh(items, viewport_height, itemHeight);
}
});
async function refresh(
items: Array<any>,
viewport_height: number,
itemHeight?: number,
) {
const { scrollTop } = viewport;
await tick(); // wait until the DOM is up to date
let content_height = top - scrollTop;
let i = start;
while (content_height < viewport_height && i < items.length) {
let row = rows[i - start];
if (!row) {
end = i + 1;
await tick(); // render the newly visible row
row = rows[i - start];
}
const row_height = (height_map[i] =
itemHeight || (row as HTMLElement).offsetHeight);
content_height += row_height;
i += 1;
}
end = i;
const remaining = items.length - end;
average_height = (top + content_height) / end;
if (end === 0) {
average_height = 0;
}
bottom = remaining * average_height;
height_map.length = items.length;
const totalHeight = height_map.reduce((x, y) => x + y, 0);
if (scrollTop + viewport_height > totalHeight) {
// If we scroll outside the viewbox scroll to the top.
viewport.scrollTo(0, totalHeight - viewport_height);
}
for (const row of rows) {
resizeObserver?.observe(row);
}
}
async function handle_scroll() {
const { scrollTop } = viewport;
const old_start = start;
for (let v = 0; v < rows.length; v += 1) {
height_map[start + v] =
itemHeight || (rows[v] as HTMLElement).offsetHeight;
}
let i = 0;
let y = 0;
while (i < items.length) {
const row_height = height_map[i] || average_height;
if (y + row_height > scrollTop) {
start = i;
top = y;
break;
}
y += row_height;
i += 1;
}
while (i < items.length) {
y += height_map[i] || average_height;
i += 1;
if (y > scrollTop + viewport_height) break;
}
end = i;
const remaining = items.length - end;
average_height = y / end;
while (i < items.length) height_map[i++] = average_height;
bottom = remaining * average_height;
// prevent jumping if we scrolled up into unknown territory
if (start < old_start) {
await tick();
let expected_height = 0;
let actual_height = 0;
for (let i = start; i < old_start; i += 1) {
if (rows[i - start]) {
expected_height += height_map[i];
actual_height +=
itemHeight || (rows[i - start] as HTMLElement).offsetHeight;
}
}
const d = actual_height - expected_height;
viewport.scrollTo(0, scrollTop + d);
}
const totalHeight = height_map.reduce((x, y) => x + y, 0);
if (scrollTop + viewport_height > totalHeight) {
// If we scroll outside the viewbox scroll to the top.
viewport.scrollTo(0, totalHeight - viewport_height);
}
}
function handleHeightChange() {
refresh(items, viewport_height, itemHeight);
}
// trigger initial refresh
onMount(() => {
rows = contents.getElementsByTagName("svelte-virtual-list-row");
resizeObserver = new ResizeObserver(handleHeightChange);
mounted = true;
});
</script>
<svelte-virtual-list-viewport
bind:this={viewport}
bind:offsetHeight={viewport_height}
onscroll={handle_scroll}
style="height: {height};"
>
<svelte-virtual-list-contents
bind:this={contents}
style="padding-top: {top}px; padding-bottom: {bottom}px;"
>
{#each visible as row (row.id)}
<svelte-virtual-list-row>
{@render children?.(row.data)}
</svelte-virtual-list-row>
{/each}
</svelte-virtual-list-contents>
</svelte-virtual-list-viewport>
<style>
svelte-virtual-list-viewport {
position: relative;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
display: block;
}
svelte-virtual-list-contents,
svelte-virtual-list-row {
display: block;
}
svelte-virtual-list-row {
overflow: hidden;
}
</style>
@Lure5134
Copy link
Author

Lure5134 commented Nov 10, 2024

Usage:

<VirtualList items={itemList} height="100%">
    {#snippet children(data)}
        <Item data={data} />
    {/snippet}
</VirtualList>

@ViniciusCestarii
Copy link

ViniciusCestarii commented Nov 14, 2024

Hey there! I was having some trouble deleting items and making it update the DOM correctly.

Luckily the Svelte address this on this tutorial.

The issue is that's it's using the index as key for the list.

So I added a function getKey to retrieve a better key from your items.

usage:

<VirtualList items={itemList} height="100%" getKey={(item) => item.id}>
   {#snippet Children(data)}
       <Itemdata={data} />
   {/snippet}
</VirtualList>
<script lang="ts" generics="T">
	import { info } from "@tauri-apps/plugin-log";
  import { onMount, tick, type Snippet } from "svelte";
  const {
    items,
    height,
    itemHeight,
    getKey,
	  Children: children,
  }: {
    items: Array<T>;
    getKey?: (item: T) => string | number;
    height: string;
    itemHeight?: number | undefined;
  	Children: Snippet<[T]>;
  } = $props();
  // read-only, but visible to consumers via bind:start
  let start = $state(0);
  let end = $state(0);
  // local state
  let height_map: Array<number> = $state([]);
  let rows: HTMLCollectionOf<Element> = $state(null!);
  let viewport: HTMLElement = $state(null!);
  let contents: HTMLElement = $state(null!);
  let viewport_height = $state(0);
  let mounted: boolean = $state(false);
  let top = $state(0);
  let bottom = $state(0);
  let average_height: number = $state(null!);
  const visible: Array<any> = $derived(
    items.slice(start, end).map((data, i) => {
      return { id: getKey?.(data) ?? i + start, data };
    })
  );
  // whenever `items` changes, invalidate the current heightmap
  $effect(() => {
    if (mounted) {
      refresh(items, viewport_height, itemHeight);
    }
  });

  async function refresh(
    items: Array<any>,
    viewport_height: number,
    itemHeight?: number
  ) {
    const { scrollTop } = viewport;
    await tick(); // wait until the DOM is up to date
    let content_height = top - scrollTop;
    let i = start;
    while (content_height < viewport_height && i < items.length) {
      let row = rows[i - start];
      if (!row) {
        end = i + 1;
        await tick(); // render the newly visible row
        row = rows[i - start];
      }
      const row_height = (height_map[i] =
        itemHeight || (row as HTMLElement).offsetHeight);
      content_height += row_height;
      i += 1;
    }
    end = i;
    const remaining = items.length - end;
    average_height = (top + content_height) / end;
    if (end === 0) {
      average_height = 0;
    }
    bottom = remaining * average_height;
    height_map.length = items.length;
    if (top >= bottom) {
      // scroll only if we are outside the viewbox
      viewport.scrollTo(0, 0);
    }
  }
  async function handle_scroll() {
    const { scrollTop } = viewport;
    const old_start = start;
    for (let v = 0; v < rows.length; v += 1) {
      height_map[start + v] =
        itemHeight || (rows[v] as HTMLElement).offsetHeight;
    }
    let i = 0;
    let y = 0;
    while (i < items.length) {
      const row_height = height_map[i] || average_height;
      if (y + row_height > scrollTop) {
        start = i;
        top = y;
        break;
      }
      y += row_height;
      i += 1;
    }
    while (i < items.length) {
      y += height_map[i] || average_height;
      i += 1;
      if (y > scrollTop + viewport_height) break;
    }
    end = i;
    const remaining = items.length - end;
    average_height = y / end;
    while (i < items.length) height_map[i++] = average_height;
    bottom = remaining * average_height;
    // prevent jumping if we scrolled up into unknown territory
    if (start < old_start) {
      await tick();
      let expected_height = 0;
      let actual_height = 0;
      for (let i = start; i < old_start; i += 1) {
        if (rows[i - start]) {
          expected_height += height_map[i];
          actual_height +=
            itemHeight || (rows[i - start] as HTMLElement).offsetHeight;
        }
      }
      const d = actual_height - expected_height;
      viewport.scrollTo(0, scrollTop + d);
    }
    // TODO if we overestimated the space these
    // rows would occupy we may need to add some
    // more. maybe we can just call handle_scroll again?
  }
  // trigger initial refresh
  onMount(() => {
    rows = contents.getElementsByTagName("svelte-virtual-list-row");
    mounted = true;
  });
</script>

<svelte-virtual-list-viewport
  bind:this={viewport}
  bind:offsetHeight={viewport_height}
  onscroll={handle_scroll}
  style="height: {height};"
>
  <svelte-virtual-list-contents
    bind:this={contents}
    style="padding-top: {top}px; padding-bottom: {bottom}px;"
  >
    {#each visible as row (row.id)}
      <svelte-virtual-list-row>
		    {@render children?.(row.data)}
      </svelte-virtual-list-row>
    {/each}
  </svelte-virtual-list-contents>
</svelte-virtual-list-viewport>

<style>
  svelte-virtual-list-viewport {
    position: relative;
    overflow-y: auto;
    -webkit-overflow-scrolling: touch;
    display: block;
  }
  svelte-virtual-list-contents,
  svelte-virtual-list-row {
    display: block;
  }
  svelte-virtual-list-row {
    overflow: hidden;
  }
</style>

@Lure5134
Copy link
Author

@ViniciusCestarii Thanks.
I have included this in the snippet and change some other things too.

@ViniciusCestarii
Copy link

@Lure5134 Nice!

@slidenerd
Copy link

slidenerd commented Feb 7, 2025

Code Sandbox link with all the issues

@Lure5134 @ViniciusCestarii

  • lots of bugs in the implementation guys
    test

  • Clicking on show more doesnt change scrollbar size even though the code has a $effect tracking items
    test

  • Clicking on any item immediately jumps the scrollbar to 0

@Lure5134
Copy link
Author

Update: The resize observer was added to fix an issue when the item size of a rendered item is changing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment