A zero-config, fast and small virtual list, grid and masonry component for React, Vue, Solid, Svelte and Angular.
See the codeA zero-config, fast and small virtual list, grid and masonry component for React, Vue, Solid, Svelte and Angular.

If you want to check the difference with the alternatives right away, see comparison section.
This project is a challenge to rethink virtualization. The goals are...
https://inokawa.github.io/virtua/
npm install virtua
If you want to support legacy browsers, you need polyfills.
scrollToIndex with smooth: true (e.g. scroll-behavior-polyfill).horizontal: true or use VMasonry. It cannot be polyfilled so use virtua@<=0.50 instead.react >= 16.14 is required.
If you use ESM and webpack 5, use react >= 18 to avoid Can't resolve react/jsx-runtime error.
import { VList } from "virtua";
const sizes = [20, 40, 80, 77];
const data = Array.from({ length: 1000 }).map((_, i) => ({
id: i,
size: sizes[i % 4],
}));
export const App = () => {
return (
<VList data={data} style={{ height: 800 }}>
{(item) => (
<div
key={item.id}
style={{
height: item.size,
background: "white",
borderBottom: "solid 1px #ccc",
}}
>
{item.id}
</div>
)}
</VList>
);
};
You can also pass elements as children directly instead of data.
vue >= 3.2 is required.
<script setup>
import { VList } from "virtua/vue";
const sizes = [20, 40, 80, 77];
const data = Array.from({ length: 1000 }).map((_, i) => ({
id: i,
size: sizes[i % 4],
}));
</script>
<template>
<VList :data="data" :style="{ height: '800px' }" #default="{ item }">
<div
:key="item.id"
:style="{
height: item.size + 'px',
background: 'white',
borderBottom: 'solid 1px #ccc',
}"
>
{{ item.id }}
</div>
</VList>
</template>
solid-js >= 1.0 is required.
import { VList } from "virtua/solid";
const sizes = [20, 40, 80, 77];
const data = Array.from({ length: 1000 }).map((_, i) => ({
id: i,
size: sizes[i % 4],
}));
export const App = () => {
return (
<VList data={data} style={{ height: "800px" }}>
{(item) => (
<div
style={{
height: item.size + "px",
background: "white",
"border-bottom": "solid 1px #ccc",
}}
>
{item.id}
</div>
)}
</VList>
);
};
svelte >= 5.0 is required.
<script lang="ts">
import { VList } from "virtua/svelte";
const sizes = [20, 40, 80, 77];
const data = Array.from({ length: 1000 }).map((_, i) => ({
id: i,
size: sizes[i % 4],
}));
</script>
<VList {data} style="height: 800px;" getKey={(item) => item.id}>
{#snippet children(item)}
<div
style="
height: {item.size}px;
background: white;
border-bottom: solid 1px #ccc;
"
>
{item.id}
</div>
{/snippet}
</VList>
@angular/core >= 20 is required. The components are zoneless-ready and work with both zone.js and provideZonelessChangeDetection.
import { Component } from "@angular/core";
import { VList } from "virtua/angular";
const sizes = [20, 40, 80, 77];
@Component({
selector: "app-root",
imports: [VList],
template: `
<virtua-vlist [data]="data" [getKey]="getKey" style="height: 800px;">
<ng-template let-item>
<div
[style.height.px]="item.size"
style="background: white; border-bottom: solid 1px #ccc;"
>
{{ item.id }}
</div>
</ng-template>
</virtua-vlist>
`,
})
export class App {
protected readonly data = Array.from({ length: 1000 }).map((_, i) => ({
id: i,
size: sizes[i % 4],
}));
protected readonly getKey = (item: { id: number }) => item.id;
}
The examples are written in React, but the same components and props are available in all frameworks.
import { VList } from "virtua";
const sizes = [20, 40, 80, 77];
const data = Array.from({ length: 1000 }).map((_, i) => ({
id: i,
size: sizes[i % 4],
}));
export const App = () => {
return (
<VList data={data} style={{ height: 400 }} horizontal>
{(item) => (
<div
key={item.id}
style={{
width: item.size,
background: "white",
borderRight: "solid 1px #ccc",
}}
>
{item.id}
</div>
)}
</VList>
);
};
VList is a recommended solution which works like a drop-in replacement of simple list built with scrollable div (or removed virtual-scroller element). For more complicated styling or markup, use Virtualizer.
import { Virtualizer } from "virtua";
const sizes = [20, 40, 80, 77];
const data = Array.from({ length: 1000 }).map((_, i) => ({
id: i,
size: sizes[i % 4],
}));
const headerHeight = 40;
export const App = () => {
return (
<div
style={{
height: 800,
overflowY: "auto",
// opt out browser's scroll anchoring on header/footer because it will conflict with scroll anchoring of virtualizer
overflowAnchor: "none",
}}
>
<div style={{ height: headerHeight }}>header</div>
<Virtualizer data={data} startMargin={headerHeight}>
{(item) => (
<div
key={item.id}
style={{
height: item.size,
background: "white",
borderBottom: "solid 1px #ccc",
}}
>
{item.id}
</div>
)}
</Virtualizer>
<div style={{ height: 600 }}>footer</div>
</div>
);
};
import { WindowVirtualizer } from "virtua";
const sizes = [20, 40, 80, 77];
const data = Array.from({ length: 1000 }).map((_, i) => ({
id: i,
size: sizes[i % 4],
}));
export const App = () => {
return (
<div style={{ padding: 200 }}>
<WindowVirtualizer data={data}>
{(item) => (
<div
key={item.id}
style={{
height: item.size,
background: "white",
borderBottom: "solid 1px #ccc",
}}
>
{item.id}
</div>
)}
</WindowVirtualizer>
</div>
);
};
For data with rows and columns such as a table, use VGrid. It virtualizes on both axes.
import { VGrid } from "virtua";
const columns = [
{ key: "id", width: 80 },
{ key: "name", width: 200 },
{ key: "email", width: 300 },
{ key: "age", width: 80 },
{ key: "joined", width: 160 },
{ key: "bio", width: 480 },
] as const;
const rows = [
// the header row has no data
null,
...Array.from({ length: 10000 }).map((_, i) => ({
id: i,
name: `User ${i}`,
email: `user${i}@example.com`,
age: 20 + (i % 50),
joined: new Date(2020, 0, 1 + i).toDateString(),
bio: `Hello, I'm user ${i}.`,
})),
];
export const App = () => {
return (
<VGrid
style={{ height: 800, border: "solid 1px #ccc", background: "white" }}
rows={rows}
rowHeight={40}
cols={columns}
colWidth="width"
headerRows={1}
>
{(row, col) => (
<div
style={{
borderRight: "solid 1px #ccc",
borderBottom: "solid 1px #ccc",
background: row === null ? "lightgray" : undefined,
}}
>
{row === null ? col.key : row[col.key]}
</div>
)}
</VGrid>
);
};
For items with different heights laid out in columns such as a gallery, use VMasonry.
import { VMasonry } from "virtua";
const sizes = [100, 180, 140, 220];
const colors = ["skyblue", "pink", "khaki", "lightgreen", "plum"];
const data = Array.from({ length: 1000 }).map((_, i) => ({
id: i,
size: sizes[i % 4],
color: colors[i % 5],
}));
export const App = () => {
return (
<VMasonry data={data} lanes={3} gap={8} style={{ height: 800 }}>
{(item) => (
<div
key={item.id}
style={{
height: item.size,
background: item.color,
}}
>
{item.id}
</div>
)}
</VMasonry>
);
};
See demo and its source code.
ResizeObserver loop completed with undelivered notifications. error?It may be dispatched by ResizeObserver in this lib as described in spec, and this is a common problem with ResizeObserver. If it bothers you, you can safely ignore it.
Especially for webpack-dev-server, you can filter out the specific error with devServer.client.overlay.runtimeErrors option.
Item sizes are measured from the DOM. If content inside an item is rendered asynchronously, such as images, videos, or components that render their content after mount, the item resizes after it's displayed. Items are also unmounted when they are out of view, so the same resize happens again every time they are mounted again.
To avoid it, make the content take its final size from the first render:
width/height or aspect-ratio of images and videos.keepMounted prop if their content can't be restored, such as a playing video.Check that each item has a unique key, such as the id of your data, not its index.
key of the element of each itemkey of the root element in the default slotdata itself, so keep the same reference for the same itemgetKey propgetKey inputIf it still happens, the shift prop may be misused.
VListHandle.viewportSize is 0 on mount?viewportSize will be calculated by ResizeObserver so it's 0 until the first measurement.
Cannot find module 'virtua/vue(solid|svelte|angular)' or its corresponding type declarations error?This package uses exports of package.json for entry point of Vue/Solid/Svelte/Angular adapter. This field can't be resolved in TypeScript with moduleResolution: node. Try moduleResolution: bundler or moduleResolution: nodenext instead.
In complex usage, especially if you re-render frequently the parent of virtual scroller or the children are tons of items, children element creation can be a performance bottle neck. That's because creating React elements is fast enough but not free and new React element instances break some of memoization inside virtual scroller.
One solution is memoization with useMemo. You can use it to reduce computation and keep the elements' instance the same. And if you want to pass state from parent to the items, using context instead of props may be better because it doesn't break the memoization.
const elements = useMemo(
() => tooLongArray.map((d) => <Component key={d.id} {...d} />),
[tooLongArray],
);
const [position, setPosition] = useState(0);
return (
<div>
<div>position: {position}</div>
<VList onScroll={(offset) => setPosition(offset)}>{elements}</VList>
</div>
);
The other solution is using render prop as children to create elements lazily. It will effectively reduce cost on start up when you render many items (>1000). An important point is that newly created elements from render prop will disable optimization possible with cached element instances. We recommend using memoized function or component to reduce calculation and re-rendering during scrolling.
// memoize render function with some memoization library
import memoize from "memoize";
const renderItem = memoize((item: Data) => {
return <Component key={item.id} data={item} />;
});
<VList data={items}>{renderItem}</VList>;
// memoize component with React.memo
import { memo } from "react";
const Component = memo(HeavyItem);
<VList data={items}>
{(item) => {
return <Component key={item.id} data={item} />;
}}
</VList>;
| virtua | react-virtuoso | react-window | @tanstack/react-virtual | react-virtualized | |
|---|---|---|---|---|---|
| Bundle size | |||||
| Vertical scroll | ✅ | ✅ | ✅ | 🟠 (needs customization) | ✅ |
| Horizontal scroll | ✅ | ✅ | ✅ | 🟠 (needs customization) | ✅ |
| Horizontal scroll in RTL direction | ✅ | ✅ | ✅ | 🟠 (isRtl) | ❌ |
| Grid (Virtualization for two dimensions) | ✅ (VGrid) | ❌ | ✅ (Grid) | 🟠 (needs customization) | ✅ (Grid) |
| Tabular data (Columns with headers) | ✅ (VGrid) | ✅ (TableVirtuoso) | ✅ (Supported) | 🟠 (needs customization) | ✅ (Table) |
| HTML table element | 🟠 (needs customization) | ✅ (TableVirtuoso) | ❌ | 🟠 (needs customization) | ❌ |
| Masonry | ✅ (VMasonry) | ✅ (VirtuosoMasonry) | ❌ | 🟠 (lanes) | ✅ (Masonry) |
| Window scroller | ✅ (WindowVirtualizer) | ✅ | ❌ | ✅ (useWindowVirtualizer) | ✅ (WindowScroller) |
| Dynamic list size | ✅ | ✅ | ✅ | ✅ | 🟠 (needs AutoSizer) |
| Dynamic item size | ✅ | ✅ | ✅ (useDynamicRowHeight) | ✅ (measureElement) | 🟠 (needs CellMeasurer and has wrong destination when scrolling to item imperatively) |
| Reverse scroll | ✅ | ✅ | ❌ | ✅ (anchorTo) | ❌ |
| Reverse scroll in iOS Safari | 🟠 (user must release scroll) | 🟠 (has glitch with unknown sized items) | ❌ | 🟠 | ❌ |
| Infinite scroll | ✅ | ✅ | 🟠 (needs react-window-infinite-loader) | ✅ | 🟠 (needs InfiniteLoader) |
| Reverse (bi-directional) infinite scroll | ✅ | ✅ | ❌ | ✅ (anchorTo) | ❌ |
| Scroll restoration | ✅ | ✅ (getState) | ❌ | ✅ (takeSnapshot) | ❌ |
| Smooth scroll | ✅ | ✅ | 🟠 (not with useDynamicRowHeight) | ✅ | ❌ |
| SSR support | ✅ (ssrCount) | ✅ (initialItemCount) | ✅ (defaultHeight) | ✅ (initialRect) | ✅ |
| Render React Server Components (RSC) as children | ✅ | ❌ | ❌ | 🟠 (needs customization) | ❌ |
| Display exceeding browser's max element size limit | ❌ | ❌ | ❌ | ❌ | ✅ |
| virtua (VGrid) | AG Grid Community | MUI X Data Grid | react-data-grid | @virtuoso.dev/data-table | react-window (Grid) | @tanstack/react-virtual | |
|---|---|---|---|---|---|---|---|
| Bundle size | |||||||
| Row / column virtualization | ✅ | ✅ | 🟠 (rows are limited to 100 per page, 💰 Pro for more) | ✅ | ✅ | ✅ | 🟠 (needs customization) |
| Dynamic row height / column width | ✅ (auto) | ✅ (autoHeight / autoSizeStrategy) | 🟠 (only row height, column width needs autosizing on demand) | 🟠 (only column width, measured once) | 🟠 (only row height, column width is measured from header cells) | ❌ | 🟠 (measureElement) |
| Pinned rows / columns | ✅ | ✅ | 💰 (Pro, rows / columns) | ✅ | 🟠 (only columns) | ❌ | 🟠 (rangeExtractor) |
| Row / column spanning | ✅ | ✅ | 🟠 (row spanning needs fixed row height) | 🟠 (only columns) | 🟠 (only column group headers) | ❌ | ❌ |
| Sticky group rows | ✅ (sectionRows) | 💰 (Enterprise) | ❌ | ❌ | ✅ (Grouped rows) | ❌ | 🟠 (rangeExtractor) |
| SSR support | ❌ | ❌ | ❌ | 🟠 (limited to the first few rows) | ❌ | ✅ (defaultHeight, defaultWidth) | ✅ (initialRect) |
| Window scroller | ❌ | ❌ | ❌ | ❌ | ✅ (useWindowScroll) | ❌ | ✅ (useWindowVirtualizer) |
| Display exceeding browser's max element size limit | ❌ | ✅ (Stretching) | ❌ | ❌ | ❌ | ❌ | ❌ |
| Use with TanStack Table | ✅ (example) | ❌ | ❌ | ❌ | ❌ | 🟠 (needs customization) | ✅ (guide) |
WIP
All contributions are welcome. If you find a problem, feel free to create an issue or a PR. If you have a question, ask in discussions.
npm install.(top 24 of 75)
6,142 followers · starred Jan 2026
309 followers · starred Mar 2025
958 followers · starred Sep 2026
892 followers · starred Aug 2025
A zero-config, fast and small virtual list, grid and masonry component for React, Vue, Solid, Svelte and Angular.
See the codeA zero-config, fast and small virtual list, grid and masonry component for React, Vue, Solid, Svelte and Angular.

If you want to check the difference with the alternatives right away, see comparison section.
This project is a challenge to rethink virtualization. The goals are...
https://inokawa.github.io/virtua/
npm install virtua
If you want to support legacy browsers, you need polyfills.
scrollToIndex with smooth: true (e.g. scroll-behavior-polyfill).horizontal: true or use VMasonry. It cannot be polyfilled so use virtua@<=0.50 instead.react >= 16.14 is required.
If you use ESM and webpack 5, use react >= 18 to avoid Can't resolve react/jsx-runtime error.
import { VList } from "virtua";
const sizes = [20, 40, 80, 77];
const data = Array.from({ length: 1000 }).map((_, i) => ({
id: i,
size: sizes[i % 4],
}));
export const App = () => {
return (
<VList data={data} style={{ height: 800 }}>
{(item) => (
<div
key={item.id}
style={{
height: item.size,
background: "white",
borderBottom: "solid 1px #ccc",
}}
>
{item.id}
</div>
)}
</VList>
);
};
You can also pass elements as children directly instead of data.
vue >= 3.2 is required.
<script setup>
import { VList } from "virtua/vue";
const sizes = [20, 40, 80, 77];
const data = Array.from({ length: 1000 }).map((_, i) => ({
id: i,
size: sizes[i % 4],
}));
</script>
<template>
<VList :data="data" :style="{ height: '800px' }" #default="{ item }">
<div
:key="item.id"
:style="{
height: item.size + 'px',
background: 'white',
borderBottom: 'solid 1px #ccc',
}"
>
{{ item.id }}
</div>
</VList>
</template>
solid-js >= 1.0 is required.
import { VList } from "virtua/solid";
const sizes = [20, 40, 80, 77];
const data = Array.from({ length: 1000 }).map((_, i) => ({
id: i,
size: sizes[i % 4],
}));
export const App = () => {
return (
<VList data={data} style={{ height: "800px" }}>
{(item) => (
<div
style={{
height: item.size + "px",
background: "white",
"border-bottom": "solid 1px #ccc",
}}
>
{item.id}
</div>
)}
</VList>
);
};
svelte >= 5.0 is required.
<script lang="ts">
import { VList } from "virtua/svelte";
const sizes = [20, 40, 80, 77];
const data = Array.from({ length: 1000 }).map((_, i) => ({
id: i,
size: sizes[i % 4],
}));
</script>
<VList {data} style="height: 800px;" getKey={(item) => item.id}>
{#snippet children(item)}
<div
style="
height: {item.size}px;
background: white;
border-bottom: solid 1px #ccc;
"
>
{item.id}
</div>
{/snippet}
</VList>
@angular/core >= 20 is required. The components are zoneless-ready and work with both zone.js and provideZonelessChangeDetection.
import { Component } from "@angular/core";
import { VList } from "virtua/angular";
const sizes = [20, 40, 80, 77];
@Component({
selector: "app-root",
imports: [VList],
template: `
<virtua-vlist [data]="data" [getKey]="getKey" style="height: 800px;">
<ng-template let-item>
<div
[style.height.px]="item.size"
style="background: white; border-bottom: solid 1px #ccc;"
>
{{ item.id }}
</div>
</ng-template>
</virtua-vlist>
`,
})
export class App {
protected readonly data = Array.from({ length: 1000 }).map((_, i) => ({
id: i,
size: sizes[i % 4],
}));
protected readonly getKey = (item: { id: number }) => item.id;
}
The examples are written in React, but the same components and props are available in all frameworks.
import { VList } from "virtua";
const sizes = [20, 40, 80, 77];
const data = Array.from({ length: 1000 }).map((_, i) => ({
id: i,
size: sizes[i % 4],
}));
export const App = () => {
return (
<VList data={data} style={{ height: 400 }} horizontal>
{(item) => (
<div
key={item.id}
style={{
width: item.size,
background: "white",
borderRight: "solid 1px #ccc",
}}
>
{item.id}
</div>
)}
</VList>
);
};
VList is a recommended solution which works like a drop-in replacement of simple list built with scrollable div (or removed virtual-scroller element). For more complicated styling or markup, use Virtualizer.
import { Virtualizer } from "virtua";
const sizes = [20, 40, 80, 77];
const data = Array.from({ length: 1000 }).map((_, i) => ({
id: i,
size: sizes[i % 4],
}));
const headerHeight = 40;
export const App = () => {
return (
<div
style={{
height: 800,
overflowY: "auto",
// opt out browser's scroll anchoring on header/footer because it will conflict with scroll anchoring of virtualizer
overflowAnchor: "none",
}}
>
<div style={{ height: headerHeight }}>header</div>
<Virtualizer data={data} startMargin={headerHeight}>
{(item) => (
<div
key={item.id}
style={{
height: item.size,
background: "white",
borderBottom: "solid 1px #ccc",
}}
>
{item.id}
</div>
)}
</Virtualizer>
<div style={{ height: 600 }}>footer</div>
</div>
);
};
import { WindowVirtualizer } from "virtua";
const sizes = [20, 40, 80, 77];
const data = Array.from({ length: 1000 }).map((_, i) => ({
id: i,
size: sizes[i % 4],
}));
export const App = () => {
return (
<div style={{ padding: 200 }}>
<WindowVirtualizer data={data}>
{(item) => (
<div
key={item.id}
style={{
height: item.size,
background: "white",
borderBottom: "solid 1px #ccc",
}}
>
{item.id}
</div>
)}
</WindowVirtualizer>
</div>
);
};
For data with rows and columns such as a table, use VGrid. It virtualizes on both axes.
import { VGrid } from "virtua";
const columns = [
{ key: "id", width: 80 },
{ key: "name", width: 200 },
{ key: "email", width: 300 },
{ key: "age", width: 80 },
{ key: "joined", width: 160 },
{ key: "bio", width: 480 },
] as const;
const rows = [
// the header row has no data
null,
...Array.from({ length: 10000 }).map((_, i) => ({
id: i,
name: `User ${i}`,
email: `user${i}@example.com`,
age: 20 + (i % 50),
joined: new Date(2020, 0, 1 + i).toDateString(),
bio: `Hello, I'm user ${i}.`,
})),
];
export const App = () => {
return (
<VGrid
style={{ height: 800, border: "solid 1px #ccc", background: "white" }}
rows={rows}
rowHeight={40}
cols={columns}
colWidth="width"
headerRows={1}
>
{(row, col) => (
<div
style={{
borderRight: "solid 1px #ccc",
borderBottom: "solid 1px #ccc",
background: row === null ? "lightgray" : undefined,
}}
>
{row === null ? col.key : row[col.key]}
</div>
)}
</VGrid>
);
};
For items with different heights laid out in columns such as a gallery, use VMasonry.
import { VMasonry } from "virtua";
const sizes = [100, 180, 140, 220];
const colors = ["skyblue", "pink", "khaki", "lightgreen", "plum"];
const data = Array.from({ length: 1000 }).map((_, i) => ({
id: i,
size: sizes[i % 4],
color: colors[i % 5],
}));
export const App = () => {
return (
<VMasonry data={data} lanes={3} gap={8} style={{ height: 800 }}>
{(item) => (
<div
key={item.id}
style={{
height: item.size,
background: item.color,
}}
>
{item.id}
</div>
)}
</VMasonry>
);
};
See demo and its source code.
ResizeObserver loop completed with undelivered notifications. error?It may be dispatched by ResizeObserver in this lib as described in spec, and this is a common problem with ResizeObserver. If it bothers you, you can safely ignore it.
Especially for webpack-dev-server, you can filter out the specific error with devServer.client.overlay.runtimeErrors option.
Item sizes are measured from the DOM. If content inside an item is rendered asynchronously, such as images, videos, or components that render their content after mount, the item resizes after it's displayed. Items are also unmounted when they are out of view, so the same resize happens again every time they are mounted again.
To avoid it, make the content take its final size from the first render:
width/height or aspect-ratio of images and videos.keepMounted prop if their content can't be restored, such as a playing video.Check that each item has a unique key, such as the id of your data, not its index.
key of the element of each itemkey of the root element in the default slotdata itself, so keep the same reference for the same itemgetKey propgetKey inputIf it still happens, the shift prop may be misused.
VListHandle.viewportSize is 0 on mount?viewportSize will be calculated by ResizeObserver so it's 0 until the first measurement.
Cannot find module 'virtua/vue(solid|svelte|angular)' or its corresponding type declarations error?This package uses exports of package.json for entry point of Vue/Solid/Svelte/Angular adapter. This field can't be resolved in TypeScript with moduleResolution: node. Try moduleResolution: bundler or moduleResolution: nodenext instead.
In complex usage, especially if you re-render frequently the parent of virtual scroller or the children are tons of items, children element creation can be a performance bottle neck. That's because creating React elements is fast enough but not free and new React element instances break some of memoization inside virtual scroller.
One solution is memoization with useMemo. You can use it to reduce computation and keep the elements' instance the same. And if you want to pass state from parent to the items, using context instead of props may be better because it doesn't break the memoization.
const elements = useMemo(
() => tooLongArray.map((d) => <Component key={d.id} {...d} />),
[tooLongArray],
);
const [position, setPosition] = useState(0);
return (
<div>
<div>position: {position}</div>
<VList onScroll={(offset) => setPosition(offset)}>{elements}</VList>
</div>
);
The other solution is using render prop as children to create elements lazily. It will effectively reduce cost on start up when you render many items (>1000). An important point is that newly created elements from render prop will disable optimization possible with cached element instances. We recommend using memoized function or component to reduce calculation and re-rendering during scrolling.
// memoize render function with some memoization library
import memoize from "memoize";
const renderItem = memoize((item: Data) => {
return <Component key={item.id} data={item} />;
});
<VList data={items}>{renderItem}</VList>;
// memoize component with React.memo
import { memo } from "react";
const Component = memo(HeavyItem);
<VList data={items}>
{(item) => {
return <Component key={item.id} data={item} />;
}}
</VList>;
| virtua | react-virtuoso | react-window | @tanstack/react-virtual | react-virtualized | |
|---|---|---|---|---|---|
| Bundle size | |||||
| Vertical scroll | ✅ | ✅ | ✅ | 🟠 (needs customization) | ✅ |
| Horizontal scroll | ✅ | ✅ | ✅ | 🟠 (needs customization) | ✅ |
| Horizontal scroll in RTL direction | ✅ | ✅ | ✅ | 🟠 (isRtl) | ❌ |
| Grid (Virtualization for two dimensions) | ✅ (VGrid) | ❌ | ✅ (Grid) | 🟠 (needs customization) | ✅ (Grid) |
| Tabular data (Columns with headers) | ✅ (VGrid) | ✅ (TableVirtuoso) | ✅ (Supported) | 🟠 (needs customization) | ✅ (Table) |
| HTML table element | 🟠 (needs customization) | ✅ (TableVirtuoso) | ❌ | 🟠 (needs customization) | ❌ |
| Masonry | ✅ (VMasonry) | ✅ (VirtuosoMasonry) | ❌ | 🟠 (lanes) | ✅ (Masonry) |
| Window scroller | ✅ (WindowVirtualizer) | ✅ | ❌ | ✅ (useWindowVirtualizer) | ✅ (WindowScroller) |
| Dynamic list size | ✅ | ✅ | ✅ | ✅ | 🟠 (needs AutoSizer) |
| Dynamic item size | ✅ | ✅ | ✅ (useDynamicRowHeight) | ✅ (measureElement) | 🟠 (needs CellMeasurer and has wrong destination when scrolling to item imperatively) |
| Reverse scroll | ✅ | ✅ | ❌ | ✅ (anchorTo) | ❌ |
| Reverse scroll in iOS Safari | 🟠 (user must release scroll) | 🟠 (has glitch with unknown sized items) | ❌ | 🟠 | ❌ |
| Infinite scroll | ✅ | ✅ | 🟠 (needs react-window-infinite-loader) | ✅ | 🟠 (needs InfiniteLoader) |
| Reverse (bi-directional) infinite scroll | ✅ | ✅ | ❌ | ✅ (anchorTo) | ❌ |
| Scroll restoration | ✅ | ✅ (getState) | ❌ | ✅ (takeSnapshot) | ❌ |
| Smooth scroll | ✅ | ✅ | 🟠 (not with useDynamicRowHeight) | ✅ | ❌ |
| SSR support | ✅ (ssrCount) | ✅ (initialItemCount) | ✅ (defaultHeight) | ✅ (initialRect) | ✅ |
| Render React Server Components (RSC) as children | ✅ | ❌ | ❌ | 🟠 (needs customization) | ❌ |
| Display exceeding browser's max element size limit | ❌ | ❌ | ❌ | ❌ | ✅ |
| virtua (VGrid) | AG Grid Community | MUI X Data Grid | react-data-grid | @virtuoso.dev/data-table | react-window (Grid) | @tanstack/react-virtual | |
|---|---|---|---|---|---|---|---|
| Bundle size | |||||||
| Row / column virtualization | ✅ | ✅ | 🟠 (rows are limited to 100 per page, 💰 Pro for more) | ✅ | ✅ | ✅ | 🟠 (needs customization) |
| Dynamic row height / column width | ✅ (auto) | ✅ (autoHeight / autoSizeStrategy) | 🟠 (only row height, column width needs autosizing on demand) | 🟠 (only column width, measured once) | 🟠 (only row height, column width is measured from header cells) | ❌ | 🟠 (measureElement) |
| Pinned rows / columns | ✅ | ✅ | 💰 (Pro, rows / columns) | ✅ | 🟠 (only columns) | ❌ | 🟠 (rangeExtractor) |
| Row / column spanning | ✅ | ✅ | 🟠 (row spanning needs fixed row height) | 🟠 (only columns) | 🟠 (only column group headers) | ❌ | ❌ |
| Sticky group rows | ✅ (sectionRows) | 💰 (Enterprise) | ❌ | ❌ | ✅ (Grouped rows) | ❌ | 🟠 (rangeExtractor) |
| SSR support | ❌ | ❌ | ❌ | 🟠 (limited to the first few rows) | ❌ | ✅ (defaultHeight, defaultWidth) | ✅ (initialRect) |
| Window scroller | ❌ | ❌ | ❌ | ❌ | ✅ (useWindowScroll) | ❌ | ✅ (useWindowVirtualizer) |
| Display exceeding browser's max element size limit | ❌ | ✅ (Stretching) | ❌ | ❌ | ❌ | ❌ | ❌ |
| Use with TanStack Table | ✅ (example) | ❌ | ❌ | ❌ | ❌ | 🟠 (needs customization) | ✅ (guide) |
WIP
All contributions are welcome. If you find a problem, feel free to create an issue or a PR. If you have a question, ask in discussions.
npm install.(top 24 of 75)
6,142 followers · starred Jan 2026
309 followers · starred Mar 2025
958 followers · starred Sep 2026
892 followers · starred Aug 2025