Docs
CodeRabbit
Cloudflare
AG Grid
SerpAPI
Netlify
OpenRouter
Neon
WorkOS
Clerk
Electric
PowerSync
Sentry
Railway
Prisma
Strapi
Unkey
CodeRabbit
Cloudflare
AG Grid
SerpAPI
Netlify
OpenRouter
Neon
WorkOS
Clerk
Electric
PowerSync
Sentry
Railway
Prisma
Strapi
Unkey
API Reference
Table API Reference
Column API Reference
Row API Reference
Cell API Reference
Header API Reference
Features API Reference
Enterprise

Svelte Example: Virtualized Columns

<script lang="ts">
  import {
    FlexRender,
    columnSizingFeature,
    columnVisibilityFeature,
    createSortedRowModel,
    createTable,
    rowSortingFeature,
    sortFns,
    tableFeatures,
  } from '@tanstack/svelte-table'
  import { createVirtualizer } from '@tanstack/svelte-virtual'
  import { get } from 'svelte/store'
  import { makeColumns, makeData } from './makeData'
  import type { Person } from './makeData'
  import './index.css'

  const _features = tableFeatures({
    columnSizingFeature,
    columnVisibilityFeature,
    rowSortingFeature,
  })

  const columns = makeColumns(1_000)
  let data = $state(makeData(1_000, columns))

  const refreshData = () => {
    data = makeData(1_000, columns)
  }

  const table = createTable({
    _features,
    _rowModels: { sortedRowModel: createSortedRowModel(sortFns) },
    columns,
    get data() {
      return data
    },
    debugTable: true,
  })

  // Important: Keep both virtualizers and the scroll container ref in the same component.
  let tableContainerRef = $state<HTMLDivElement | undefined>(undefined)

  const visibleColumns = $derived(table.getVisibleLeafColumns())
  const rows = $derived(table.getRowModel().rows)

  // We are using a slightly different virtualization strategy for columns (compared to virtual rows)
  // in order to support dynamic row heights.
  const columnVirtualizer = createVirtualizer<
    HTMLDivElement,
    HTMLTableCellElement
  >({
    get count() {
      return visibleColumns.length
    },
    estimateSize: (index) => visibleColumns[index].getSize(), // estimate width of each column for accurate scrollbar dragging
    getScrollElement: () => tableContainerRef ?? null,
    horizontal: true,
    overscan: 3, // how many columns to render on each side off screen (adjust this for performance)
  })

  // dynamic row height virtualization - alternatively you could use a simpler fixed row height strategy without `measureElement`
  const rowVirtualizer = createVirtualizer<
    HTMLDivElement,
    HTMLTableRowElement
  >({
    get count() {
      return rows.length
    },
    estimateSize: () => 33, // estimate row height for accurate scrollbar dragging
    getScrollElement: () => tableContainerRef ?? null,
    // measure dynamic row height, except in firefox because it measures table border height incorrectly
    measureElement:
      typeof window !== 'undefined' &&
      navigator.userAgent.indexOf('Firefox') === -1
        ? (element) => element.getBoundingClientRect().height
        : undefined,
    overscan: 5,
  })

  // When the container ref becomes available, update both virtualizers
  // so they pick up the scroll element and set up scroll observers.
  $effect(() => {
    if (tableContainerRef) {
      const getEl = () => tableContainerRef ?? null
      get(columnVirtualizer).setOptions({ getScrollElement: getEl })
      get(rowVirtualizer).setOptions({ getScrollElement: getEl })
    }
  })

  // When row/column counts change, push the new counts to the virtualizers.
  // The svelte-virtual store adapter doesn't reactively track getter options.
  $effect(() => {
    get(rowVirtualizer).setOptions({ count: rows.length })
  })
  $effect(() => {
    get(columnVirtualizer).setOptions({ count: visibleColumns.length })
  })

  // Different virtualization strategy for columns - instead of absolute and translateY,
  // we add empty columns to the left and right
  const virtualPaddingLeft = $derived.by(() => {
    const vcs = $columnVirtualizer.getVirtualItems()
    return vcs.length ? (vcs[0]?.start ?? 0) : undefined
  })

  const virtualPaddingRight = $derived.by(() => {
    const vcs = $columnVirtualizer.getVirtualItems()
    if (!vcs.length) return undefined
    return $columnVirtualizer.getTotalSize() - (vcs[vcs.length - 1]?.end ?? 0)
  })

  // Svelte action to measure dynamic row heights via the virtualizer
  function measureElement(node: HTMLTableRowElement) {
    get(rowVirtualizer).measureElement(node)
  }
</script>

<div class="app">
  <div>({columns.length.toLocaleString()} columns)</div>
  <div>({data.length.toLocaleString()} rows)</div>
  <button onclick={refreshData}>Refresh Data</button>
  <div
    class="container"
    bind:this={tableContainerRef}
    style="overflow: auto; position: relative; height: 800px;"
  >
    <!-- Even though we're still using semantic table tags, we must use CSS grid and flexbox for dynamic row heights -->
    <table style="display: grid;">
      <thead style="display: grid; position: sticky; top: 0px; z-index: 1;">
        {#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
          <tr style="display: flex; width: 100%;">
            {#if virtualPaddingLeft}
              <!-- fake empty column to the left for virtualization scroll padding -->
              <th style="display: flex; width: {virtualPaddingLeft}px;"></th>
            {/if}
            {#each $columnVirtualizer.getVirtualItems() as virtualColumn (virtualColumn.index)}
              {@const header = headerGroup.headers[virtualColumn.index]}
              <th style="display: flex; width: {header.getSize()}px;">
                <div
                  class={header.column.getCanSort()
                    ? 'cursor-pointer select-none'
                    : ''}
                  role="button"
                  tabindex="0"
                  onclick={header.column.getToggleSortingHandler()}
                  onkeydown={(e) => {
                    if (e.key === 'Enter' || e.key === ' ') {
                      header.column.getToggleSortingHandler()?.(e)
                    }
                  }}
                >
                  <FlexRender header={header} />
                  {#if header.column.getIsSorted() === 'asc'}
                    {' '}🔼
                  {:else if header.column.getIsSorted() === 'desc'}
                    {' '}🔽
                  {/if}
                </div>
              </th>
            {/each}
            {#if virtualPaddingRight}
              <!-- fake empty column to the right for virtualization scroll padding -->
              <th style="display: flex; width: {virtualPaddingRight}px;"></th>
            {/if}
          </tr>
        {/each}
      </thead>
      <tbody
        style="display: grid; height: {$rowVirtualizer.getTotalSize()}px; position: relative;"
      >
        {#each $rowVirtualizer.getVirtualItems() as virtualRow (virtualRow.index)}
          {@const row = rows[virtualRow.index]}
          {@const visibleCells = row.getVisibleCells()}
          <tr
            data-index={virtualRow.index}
            use:measureElement
            style="display: flex; position: absolute; transform: translateY({virtualRow.start}px); width: 100%;"
          >
            {#if virtualPaddingLeft}
              <!-- fake empty column to the left for virtualization scroll padding -->
              <td style="display: flex; width: {virtualPaddingLeft}px;"></td>
            {/if}
            {#each $columnVirtualizer.getVirtualItems() as virtualColumn (virtualColumn.index)}
              {@const cell = visibleCells[virtualColumn.index]}
              <td style="display: flex; width: {cell.column.getSize()}px;">
                <FlexRender cell={cell} />
              </td>
            {/each}
            {#if virtualPaddingRight}
              <!-- fake empty column to the right for virtualization scroll padding -->
              <td style="display: flex; width: {virtualPaddingRight}px;"></td>
            {/if}
          </tr>
        {/each}
      </tbody>
    </table>
  </div>
</div>
<script lang="ts">
  import {
    FlexRender,
    columnSizingFeature,
    columnVisibilityFeature,
    createSortedRowModel,
    createTable,
    rowSortingFeature,
    sortFns,
    tableFeatures,
  } from '@tanstack/svelte-table'
  import { createVirtualizer } from '@tanstack/svelte-virtual'
  import { get } from 'svelte/store'
  import { makeColumns, makeData } from './makeData'
  import type { Person } from './makeData'
  import './index.css'

  const _features = tableFeatures({
    columnSizingFeature,
    columnVisibilityFeature,
    rowSortingFeature,
  })

  const columns = makeColumns(1_000)
  let data = $state(makeData(1_000, columns))

  const refreshData = () => {
    data = makeData(1_000, columns)
  }

  const table = createTable({
    _features,
    _rowModels: { sortedRowModel: createSortedRowModel(sortFns) },
    columns,
    get data() {
      return data
    },
    debugTable: true,
  })

  // Important: Keep both virtualizers and the scroll container ref in the same component.
  let tableContainerRef = $state<HTMLDivElement | undefined>(undefined)

  const visibleColumns = $derived(table.getVisibleLeafColumns())
  const rows = $derived(table.getRowModel().rows)

  // We are using a slightly different virtualization strategy for columns (compared to virtual rows)
  // in order to support dynamic row heights.
  const columnVirtualizer = createVirtualizer<
    HTMLDivElement,
    HTMLTableCellElement
  >({
    get count() {
      return visibleColumns.length
    },
    estimateSize: (index) => visibleColumns[index].getSize(), // estimate width of each column for accurate scrollbar dragging
    getScrollElement: () => tableContainerRef ?? null,
    horizontal: true,
    overscan: 3, // how many columns to render on each side off screen (adjust this for performance)
  })

  // dynamic row height virtualization - alternatively you could use a simpler fixed row height strategy without `measureElement`
  const rowVirtualizer = createVirtualizer<
    HTMLDivElement,
    HTMLTableRowElement
  >({
    get count() {
      return rows.length
    },
    estimateSize: () => 33, // estimate row height for accurate scrollbar dragging
    getScrollElement: () => tableContainerRef ?? null,
    // measure dynamic row height, except in firefox because it measures table border height incorrectly
    measureElement:
      typeof window !== 'undefined' &&
      navigator.userAgent.indexOf('Firefox') === -1
        ? (element) => element.getBoundingClientRect().height
        : undefined,
    overscan: 5,
  })

  // When the container ref becomes available, update both virtualizers
  // so they pick up the scroll element and set up scroll observers.
  $effect(() => {
    if (tableContainerRef) {
      const getEl = () => tableContainerRef ?? null
      get(columnVirtualizer).setOptions({ getScrollElement: getEl })
      get(rowVirtualizer).setOptions({ getScrollElement: getEl })
    }
  })

  // When row/column counts change, push the new counts to the virtualizers.
  // The svelte-virtual store adapter doesn't reactively track getter options.
  $effect(() => {
    get(rowVirtualizer).setOptions({ count: rows.length })
  })
  $effect(() => {
    get(columnVirtualizer).setOptions({ count: visibleColumns.length })
  })

  // Different virtualization strategy for columns - instead of absolute and translateY,
  // we add empty columns to the left and right
  const virtualPaddingLeft = $derived.by(() => {
    const vcs = $columnVirtualizer.getVirtualItems()
    return vcs.length ? (vcs[0]?.start ?? 0) : undefined
  })

  const virtualPaddingRight = $derived.by(() => {
    const vcs = $columnVirtualizer.getVirtualItems()
    if (!vcs.length) return undefined
    return $columnVirtualizer.getTotalSize() - (vcs[vcs.length - 1]?.end ?? 0)
  })

  // Svelte action to measure dynamic row heights via the virtualizer
  function measureElement(node: HTMLTableRowElement) {
    get(rowVirtualizer).measureElement(node)
  }
</script>

<div class="app">
  <div>({columns.length.toLocaleString()} columns)</div>
  <div>({data.length.toLocaleString()} rows)</div>
  <button onclick={refreshData}>Refresh Data</button>
  <div
    class="container"
    bind:this={tableContainerRef}
    style="overflow: auto; position: relative; height: 800px;"
  >
    <!-- Even though we're still using semantic table tags, we must use CSS grid and flexbox for dynamic row heights -->
    <table style="display: grid;">
      <thead style="display: grid; position: sticky; top: 0px; z-index: 1;">
        {#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
          <tr style="display: flex; width: 100%;">
            {#if virtualPaddingLeft}
              <!-- fake empty column to the left for virtualization scroll padding -->
              <th style="display: flex; width: {virtualPaddingLeft}px;"></th>
            {/if}
            {#each $columnVirtualizer.getVirtualItems() as virtualColumn (virtualColumn.index)}
              {@const header = headerGroup.headers[virtualColumn.index]}
              <th style="display: flex; width: {header.getSize()}px;">
                <div
                  class={header.column.getCanSort()
                    ? 'cursor-pointer select-none'
                    : ''}
                  role="button"
                  tabindex="0"
                  onclick={header.column.getToggleSortingHandler()}
                  onkeydown={(e) => {
                    if (e.key === 'Enter' || e.key === ' ') {
                      header.column.getToggleSortingHandler()?.(e)
                    }
                  }}
                >
                  <FlexRender header={header} />
                  {#if header.column.getIsSorted() === 'asc'}
                    {' '}🔼
                  {:else if header.column.getIsSorted() === 'desc'}
                    {' '}🔽
                  {/if}
                </div>
              </th>
            {/each}
            {#if virtualPaddingRight}
              <!-- fake empty column to the right for virtualization scroll padding -->
              <th style="display: flex; width: {virtualPaddingRight}px;"></th>
            {/if}
          </tr>
        {/each}
      </thead>
      <tbody
        style="display: grid; height: {$rowVirtualizer.getTotalSize()}px; position: relative;"
      >
        {#each $rowVirtualizer.getVirtualItems() as virtualRow (virtualRow.index)}
          {@const row = rows[virtualRow.index]}
          {@const visibleCells = row.getVisibleCells()}
          <tr
            data-index={virtualRow.index}
            use:measureElement
            style="display: flex; position: absolute; transform: translateY({virtualRow.start}px); width: 100%;"
          >
            {#if virtualPaddingLeft}
              <!-- fake empty column to the left for virtualization scroll padding -->
              <td style="display: flex; width: {virtualPaddingLeft}px;"></td>
            {/if}
            {#each $columnVirtualizer.getVirtualItems() as virtualColumn (virtualColumn.index)}
              {@const cell = visibleCells[virtualColumn.index]}
              <td style="display: flex; width: {cell.column.getSize()}px;">
                <FlexRender cell={cell} />
              </td>
            {/each}
            {#if virtualPaddingRight}
              <!-- fake empty column to the right for virtualization scroll padding -->
              <td style="display: flex; width: {virtualPaddingRight}px;"></td>
            {/if}
          </tr>
        {/each}
      </tbody>
    </table>
  </div>
</div>