addVirtualScroll
addVirtualScroll enables virtualized table rendering for large datasets (10,000+ rows). Only visible rows plus a configurable buffer are rendered in the DOM, dramatically improving performance.
TableViewModel#pageRows instead of TableViewModel#rows.Basic Usage
<script>
import { writable } from 'svelte/store';
import { createTable } from '@humanspeak/svelte-headless-table';
import { addVirtualScroll } from '@humanspeak/svelte-headless-table/plugins';
const data = writable(largeDataset); // 10,000+ rows
const table = createTable(data, {
virtualScroll: addVirtualScroll({
estimatedRowHeight: 48,
bufferSize: 10
})
});
const columns = table.createColumns([
table.column({ header: 'Name', accessor: 'name' }),
table.column({ header: 'Email', accessor: 'email' }),
]);
const {
headerRows,
pageRows,
tableAttrs,
tableBodyAttrs,
pluginStates,
visibleColumns
} = table.createViewModel(columns);
const {
virtualScroll,
topSpacerHeight,
bottomSpacerHeight,
measureRowAction
} = pluginStates.virtualScroll;
</script>
<div class="table-container" use:virtualScroll>
<table {...$tableAttrs}>
<thead>
<!-- header rows -->
</thead>
<tbody {...$tableBodyAttrs}>
<!-- Top spacer -->
{#if $topSpacerHeight > 0}
<tr>
<td colspan={$visibleColumns.length} style="height: {$topSpacerHeight}px; padding: 0; border: none;"></td>
</tr>
{/if}
<!-- Visible rows -->
{#each $pageRows as row (row.id)}
<Subscribe attrs={row.attrs()} let:attrs>
<tr {...attrs} use:measureRowAction={row.id}>
{#each row.cells as cell (cell.id)}
<Subscribe attrs={cell.attrs()} let:attrs>
<td {...attrs}>
<Render of={cell.render()} />
</td>
</Subscribe>
{/each}
</tr>
</Subscribe>
{/each}
<!-- Bottom spacer -->
{#if $bottomSpacerHeight > 0}
<tr>
<td colspan={$visibleColumns.length} style="height: {$bottomSpacerHeight}px; padding: 0; border: none;"></td>
</tr>
{/if}
</tbody>
</table>
</div>
<style>
.table-container {
height: 500px;
overflow-y: auto;
}
table {
table-layout: fixed;
}
</style><script>
import { writable } from 'svelte/store';
import { createTable } from '@humanspeak/svelte-headless-table';
import { addVirtualScroll } from '@humanspeak/svelte-headless-table/plugins';
const data = writable(largeDataset); // 10,000+ rows
const table = createTable(data, {
virtualScroll: addVirtualScroll({
estimatedRowHeight: 48,
bufferSize: 10
})
});
const columns = table.createColumns([
table.column({ header: 'Name', accessor: 'name' }),
table.column({ header: 'Email', accessor: 'email' }),
]);
const {
headerRows,
pageRows,
tableAttrs,
tableBodyAttrs,
pluginStates,
visibleColumns
} = table.createViewModel(columns);
const {
virtualScroll,
topSpacerHeight,
bottomSpacerHeight,
measureRowAction
} = pluginStates.virtualScroll;
</script>
<div class="table-container" use:virtualScroll>
<table {...$tableAttrs}>
<thead>
<!-- header rows -->
</thead>
<tbody {...$tableBodyAttrs}>
<!-- Top spacer -->
{#if $topSpacerHeight > 0}
<tr>
<td colspan={$visibleColumns.length} style="height: {$topSpacerHeight}px; padding: 0; border: none;"></td>
</tr>
{/if}
<!-- Visible rows -->
{#each $pageRows as row (row.id)}
<Subscribe attrs={row.attrs()} let:attrs>
<tr {...attrs} use:measureRowAction={row.id}>
{#each row.cells as cell (cell.id)}
<Subscribe attrs={cell.attrs()} let:attrs>
<td {...attrs}>
<Render of={cell.render()} />
</td>
</Subscribe>
{/each}
</tr>
</Subscribe>
{/each}
<!-- Bottom spacer -->
{#if $bottomSpacerHeight > 0}
<tr>
<td colspan={$visibleColumns.length} style="height: {$bottomSpacerHeight}px; padding: 0; border: none;"></td>
</tr>
{/if}
</tbody>
</table>
</div>
<style>
.table-container {
height: 500px;
overflow-y: auto;
}
table {
table-layout: fixed;
}
</style>overflow-y: auto for virtualization to work.Infinite Scroll
addVirtualScroll supports infinite scroll with the onLoadMore and hasMore options:
const hasMore = writable(true);
const table = createTable(data, {
virtualScroll: addVirtualScroll({
estimatedRowHeight: 48,
bufferSize: 10,
loadMoreThreshold: 200,
hasMore,
onLoadMore: async () => {
const moreData = await fetchMoreItems();
data.update(d => [...d, ...moreData]);
if (noMoreData) {
hasMore.set(false);
}
}
})
});const hasMore = writable(true);
const table = createTable(data, {
virtualScroll: addVirtualScroll({
estimatedRowHeight: 48,
bufferSize: 10,
loadMoreThreshold: 200,
hasMore,
onLoadMore: async () => {
const moreData = await fetchMoreItems();
data.update(d => [...d, ...moreData]);
if (noMoreData) {
hasMore.set(false);
}
}
})
});Sparse Mode (server-paged datasets)
Infinite scroll is append-only: reaching row 2,000,000 means loading every row before it. For datasets that are too large to materialize, pass totalRows to opt into sparse mode. The caller owns fetching and eviction; the plugin owns geometry.
In sparse mode the table’s data store holds only the rows currently resident, dataOffset says where that window starts in the full dataset, and onRangeChange tells you when to move it. All indices the plugin reports — visibleRange, viewportRange, scrollToIndex, virtualIndex — are absolute indices into the full dataset.
const totalRows = writable(0); // filled from a cheap count query
const dataOffset = writable(0); // absolute index of data[0]
const data = writable([]); // only the resident window
const table = createTable(data, {
virtualScroll: addVirtualScroll({
estimatedRowHeight: 32,
totalRows,
dataOffset,
onRangeChange: async ({ start, end }, { signal }) => {
// Fetch just the pages intersecting the range, and drop the rest.
const { rows, offset } = await fetchWindow(start, end, { signal });
// The range moves faster than a round trip. Without this check a slow
// early response can land after a fast later one and republish a window
// the user has already scrolled away from.
if (signal.aborted) return;
dataOffset.set(offset);
data.set(rows);
}
})
});const totalRows = writable(0); // filled from a cheap count query
const dataOffset = writable(0); // absolute index of data[0]
const data = writable([]); // only the resident window
const table = createTable(data, {
virtualScroll: addVirtualScroll({
estimatedRowHeight: 32,
totalRows,
dataOffset,
onRangeChange: async ({ start, end }, { signal }) => {
// Fetch just the pages intersecting the range, and drop the rest.
const { rows, offset } = await fetchWindow(start, end, { signal });
// The range moves faster than a round trip. Without this check a slow
// early response can land after a fast later one and republish a window
// the user has already scrolled away from.
if (signal.aborted) return;
dataOffset.set(offset);
data.set(rows);
}
})
});The rendered markup is identical to the basic example — spacer rows, use:virtualScroll, use:measureRowAction. The scroll container is sized from totalRows, so the user can drag straight to row 2,000,000 and only that page is fetched.
visibleRange, or render skeleton rows from your own cache, to avoid a blank viewport during a fetch.Browser scroll-height ceiling
Browsers cap how tall an element can be — roughly 16,777,216px in Chrome and Safari, with other engines varying. At 32px rows a naively-sized container would hit that wall at ~520,000 rows, and everything past it would be unreachable: the scrollbar bottoms out, and scrollToIndex doesn’t help because the browser clamps scrollTo to the element’s real height too.
Sparse mode handles this for you. The container is sized to at most maxScrollHeight (default 16_000_000), and when the dataset is larger the scroll position is mapped onto it proportionally. Rows still render at their natural height and lay out 1:1 around the viewport, so there is no drift or jitter — the mapping only decides which row anchors the top of the screen.
The tradeoff is scroll travel. Over a 4,000,000-row dataset at 32px, the range is compressed ~8×, so one wheel notch covers ~24 rows instead of ~3. Datasets that fit under the cap are unaffected: the mapping is the identity and nothing changes.
A few things to know:
- Row heights are uniform in sparse mode. Rows outside the resident window can’t be measured, so geometry uses the running average of the measured ones. Measurements still refine that average;
getRowHeightfeeds it but doesn’t position individual rows. onRangeChangefires more than once during startup as the viewport is measured. Debounce or de-duplicate in the handler if your fetch is expensive.- Async handlers must respect
signal. It is aborted as soon as a newer range supersedes the one being served, and when the scroll container is destroyed. Pass it tofetchto cancel the request, and re-checksignal.abortedbefore writing todata/dataOffset— otherwise out-of-order responses will republish a stale window. - It is safe to update stores from
onRangeChange. The callback is deferred to a microtask, so it never writes to a store mid-derivation. - Keep
dataOffsetanddatain sync. A stale offset shifts every rendered row.
One instance per table
Scroll position, viewport height and the measured-row-height cache live in the closure addVirtualScroll() returns, so they survive a view model rebuild. A component whose columns are derived rebuilds its view model on every reactive pass; without this, each rebuild would hand the mounted container to a discarded instance and the table would stop responding to scrolling.
The tradeoff is that one result drives one rendered table. Construct it inline, per table:
// Correct — each table gets its own geometry.
const table = createTable(data, { virtualScroll: addVirtualScroll({ ... }) });// Correct — each table gets its own geometry.
const table = createTable(data, { virtualScroll: addVirtualScroll({ ... }) });Hoisting a single result to module scope and passing it to two createTable calls makes both tables share a scroll position and a height cache. That warns in the console.
To scroll two views of the same data independently, give each its own table over the same data store:
const data = writable(items);
const left = createTable(data, { virtualScroll: addVirtualScroll({ ... }) });
const right = createTable(data, { virtualScroll: addVirtualScroll({ ... }) });const data = writable(items);
const left = createTable(data, { virtualScroll: addVirtualScroll({ ... }) });
const right = createTable(data, { virtualScroll: addVirtualScroll({ ... }) });createViewModel call while a container is mounted — so use two tables when you need two independent viewports.Options
addVirtualScroll.const table = createTable(data, {
virtualScroll: addVirtualScroll({ ... }),
});const table = createTable(data, {
virtualScroll: addVirtualScroll({ ... }),
});estimatedRowHeight?: number
Default 40. Estimated height of each row in pixels. Used for initial calculations before rows are measured. Actual heights are measured automatically.
bufferSize?: number
Default 10. Number of rows to render above and below the visible area. Higher values reduce flicker during fast scrolling but render more DOM nodes.
onLoadMore?: () => void | Promise<void>
Callback fired when more data should be loaded (infinite scroll). Return a promise to indicate when loading is complete.
hasMore?: Writable<boolean> | boolean
Whether there is more data available to load. Can be a boolean or a Writable store.
loadMoreThreshold?: number
Default 200. Number of pixels from the bottom to trigger onLoadMore.
getRowHeight?: (item: Item) => number
Optional function to get the exact height of a specific row. Enables precise variable row heights.
totalRows?: Readable<number> | number
Total number of rows in the full dataset, independent of how many are loaded. Supplying this opts into sparse mode.
dataOffset?: Readable<number> | number
Default 0. Absolute index of the first row held in the table’s data store. Sparse mode only — keep it in sync with data whenever the resident window moves.
maxScrollHeight?: number
Default 16_000_000. Largest height, in pixels, to give the scroll container. Sparse mode only. When totalRows × rowHeight exceeds this, the scroll range is compressed onto it so the tail of the dataset stays reachable — see the scroll-height ceiling.
onRangeChange?: (range: { start: number; end: number }, context: { signal: AbortSignal }) => void
Fired whenever the visible range changes, so a caller can fetch the pages intersecting it and evict the ones that have scrolled away. In sparse mode the range is in absolute dataset indices. Invoked on a microtask, so it is safe to update stores from within it.
context.signal is aborted as soon as a newer range supersedes this one, or when the scroll container is destroyed. Async handlers should pass it to fetch and re-check signal.aborted before publishing results.
Plugin State
addVirtualScroll.const { headerRows, pageRows, pluginStates } = table.createViewModel(columns);
const { ... } = pluginStates.virtualScroll;const { headerRows, pageRows, pluginStates } = table.createViewModel(columns);
const { ... } = pluginStates.virtualScroll;virtualScroll: Action<HTMLElement>
Svelte action to attach to the scroll container. Handles scroll event listeners and viewport tracking.
topSpacerHeight: Readable<number>
Height of the top spacer element in pixels.
bottomSpacerHeight: Readable<number>
Height of the bottom spacer element in pixels.
measureRowAction: Action<HTMLElement, string>
Svelte action to attach to each row for automatic height measurement. Usage: <tr use:measureRowAction={row.id}>.
scrollToIndex: (index: number, options?) => void
Scroll to a specific row index programmatically. In sparse mode the index is absolute and may point outside the resident window, and every align mode accounts for any scroll-range compression.
// Scroll options
scrollToIndex(100, {
align: 'start' | 'center' | 'end' | 'auto',
behavior: 'auto' | 'smooth'
});// Scroll options
scrollToIndex(100, {
align: 'start' | 'center' | 'end' | 'auto',
behavior: 'auto' | 'smooth'
});visibleRange: Readable<{ start: number; end: number }>
Range of currently rendered row indices, padded by bufferSize on both ends. Absolute dataset indices in sparse mode. Use this to decide what to fetch; use viewportRange to tell the user what they are looking at.
viewportRange: Readable<{ start: number; end: number }>
Rows actually intersecting the viewport, with no render buffer — the range a “rows N–M of T” footer, a scroll-progress readout or a “jump to row” indicator should report. Absolute dataset indices in sparse mode, with any scroll-range compression already undone, so you never re-derive that mapping yourself. end is exclusive.
<tfoot>
<tr><td>
Rows {$viewportRange.start + 1}–{$viewportRange.end} of {$totalRows}
</td></tr>
</tfoot><tfoot>
<tr><td>
Rows {$viewportRange.start + 1}–{$viewportRange.end} of {$totalRows}
</td></tr>
</tfoot><thead>, a caption, a toolbar — shifts where row 0 begins inside the scroll container. The plugin measures that offset from the first rendered row, so this range tracks what's on screen with no configuration. A position: sticky header additionally covers the top of the viewport at every scroll position; add use:measureHeaderAction to it so the rows underneath are excluded too.measureHeaderAction: Action<HTMLElement>
Attach to content that paints over the top of the viewport — in practice a position: sticky <thead>.
<thead class="sticky top-0 z-10" use:measureHeaderAction><thead class="sticky top-0 z-10" use:measureHeaderAction>A sticky header keeps the space it occupies in the document, so the plugin already knows where the rows start, but it also goes on hiding the top of the viewport as you scroll. Without this, the rows behind it are still counted as visible and viewportRange names rows the user can’t see — and scrollToIndex parks its target underneath the header instead of below it.
You don’t need it for a header that scrolls away with the rows; that case is measured automatically. Attaching it to one anyway is harmless, since it reports no overlap once it’s out of view.
totalHeight: Readable<number>
Total height of all rows (for scroll container sizing).
totalRows: Readable<number>
Total number of rows in the dataset. In sparse mode this is the configured dataset total rather than the number of rows loaded.
dataOffset: Readable<number>
Absolute index of the first row held in the table’s data store. Always 0 outside sparse mode.
renderedRows: Readable<number>
Number of rows currently rendered in the DOM.
scrollTop: Readable<number>
Current scroll position of the container.
viewportHeight: Readable<number>
Height of the scroll container viewport.
isLoading: Readable<boolean>
Whether more data is currently being loaded.
hasMore: Readable<boolean>
Whether there is more data available to load.
measureRow: (rowId: string, height: number) => void
Manually notify the plugin that a row has been measured. Usually not needed when using measureRowAction.
How It Works
Spacer Rows: Instead of CSS transforms, the plugin uses spacer
<tr>elements to maintain scroll position. This preserves semantic table structure and accessibility.Height Management: Row heights are cached as they’re measured. The
HeightManagerclass tracks measured heights and calculates average heights for unmeasured rows.Visible Range Calculation: Based on scroll position, viewport height, and cached row heights, the plugin calculates which rows should be rendered.
Automatic Measurement: The
measureRowActionuses ResizeObserver to automatically measure and cache row heights as they render.
Performance Tips
- Use
table-layout: fixedon your<table>element. With virtual scroll, only a subset of rows is rendered at any time. The defaulttable-layout: autorecalculates column widths based on visible content, causing header columns to shift as you scroll through rows with varying content lengths.table-layout: fixedlocks column widths to the header, preventing layout jitter. - Use a reasonable
bufferSize(10-20) to balance between smooth scrolling and DOM size - The
estimatedRowHeightdoesn’t need to be exact - actual heights are measured automatically - For very large datasets (100,000+ rows), combine server-side paging with sparse mode so only the pages you look at are ever held in memory
- Avoid complex components in cells that cause expensive re-renders