VirtualTable
A virtualized table component for efficiently rendering large datasets. This component uses tanstack virtual API to render only the visible rows, improving performance.
Quick Start
- Installation
npm install @adaptavant/eds-core- Import
import { VirtualTable } from '@adaptavant/eds-core';- Added
1.20.0
Basic Usage
The core functionality and features of <VirtualTable /> are the same as the <Table /> component. All requirements outlined in the Table documentation are applicable here as well.
In a virtualized table, rows are rendered dynamically as you scroll, and not all at once. To ensure proper alignment between the header and body rows, each column must have a fixed width. That’s why the size property in the column definition is mandatory — it guarantees consistent layout and alignment across virtualized rows and the static header.
Note: Pagination is not available in <VirtualTable /> as it's designed to be virtually scrollable, making pagination unnecessary.
const [data, setData] = React.useState([]);
const [loading, setLoading] = React.useState(true);
const basicColumnDef = [
{
header: "FirstName",
accessorKey: "name.first",
size: 150,
},
{
header: "LastName",
accessorKey: "name.last",
size: 150,
},
{
header: "Age",
accessorKey: "dob.age",
size: 80,
},
{
header: "Phone",
accessorKey: "phone",
size: 200,
},
{
header: "Country",
accessorKey: "location.country",
size: 150,
},
];
React.useEffect(() => {
fetch("https://randomuser.me/api/?results=200")
.then((response) => response.json())
.then((data) => {
setData(data.results);
setLoading(false);
})
.catch((error) => {
console.log(error);
setLoading(false);
});
}, []);
const columns = React.useMemo(() => basicColumnDef, []);
if (loading) {
return (
<Box className="flex justify-center w-full p-4 h-[200px]">
<Box className="flex flex-col items-center justify-center">
<Loading size="48" />
</Box>
</Box>
);
}
return (
<Box className="flex align-center justify-center">
<VirtualTable
columns={columns}
data={data}
rowHeight={40}
containerHeight={350}
overscanRowCount={8}
/>
</Box>
);
ScrollToIndex
In certain scenarios, consumers may need to programmatically scroll to a specific row in the virtualized table. The scrollToIndex function provided by the tanstack/react-virtual virtualizer can be accessed through the onVirtualizerReady callback. This callback exposes a minimal virtualizer instance, allowing access to the scrollToIndex method. In the future, if additional utilities are needed, they can be exposed through this instance as well.
const [data, setData] = React.useState([]);
const [virtualizer, setVirtualizer] = React.useState(null);
const [scrollIndex, setScrollIndex] = React.useState(null);
const [loading, setLoading] = React.useState(true);
const isSafari = /^((?!chrome|android).)*safari/i.test(typeof navigator !== "undefined" ? navigator.userAgent : "");
React.useEffect(() => {
fetch("https://randomuser.me/api/?results=200")
.then((response) => response.json())
.then((data) => {
setData(data.results);
setLoading(false);
})
.catch((error) => {
console.log(error);
setLoading(false);
});
}, []);
const basicColumnDef = [
{
header: "FirstName",
accessorKey: "name.first",
size: 150,
},
{
header: "LastName",
accessorKey: "name.last",
size: 150,
},
{
header: "Age",
accessorKey: "dob.age",
size: 80,
},
{
header: "Phone",
accessorKey: "phone",
size: 200,
},
{
header: "Country",
accessorKey: "location.country",
size: 150,
},
];
const columns = React.useMemo(() => basicColumnDef, []);
const handleScroll = () => {
const randomIndex = Math.floor(Math.random() * data.length);
setScrollIndex(randomIndex); // Update state to show in button
virtualizer?.scrollToIndex(randomIndex, {
align: "center",
behavior: isSafari ? "auto" : "smooth",
});
};
if (loading) {
return (
<Box className="flex justify-center w-full p-4 h-[200px]">
<Loading size="48" />
</Box>
);
}
return (
<Box className="flex flex-col items-center gap-4 w-full">
<Button onClick={handleScroll}>
{scrollIndex !== null
? `Scroll to Row ${scrollIndex}`
: "Scroll to a Random Row"}
</Button>
<VirtualTable
columns={columns}
data={data}
rowHeight={40}
containerHeight={350}
overscanRowCount={8}
onVirtualizerReady={(v) => setVirtualizer(v)}
/>
</Box>
);
Multiple Row Selection
Enable multiple row selection by setting rowSelection.mode: 'multiple'. This renders a leftmost checkbox column. Because <VirtualTable /> has no pagination, the header checkbox selects every row in the table.
Selection is fully controlled — the consumer owns rowSelection.selectedRowIds and updates it in rowSelection.onRowSelectionChange, which receives the full new array of selected IDs.
Make sure
getRowIdis used alongsiderowSelection.selectedRowIdsto ensure IDs match correctly. Without it, TanStack Table defaults to string-based array indices.
Note: In multiple mode, only the checkbox toggles selection — the row itself shows a hover affordance but is not clickable.
const firstNames = ["Torie", "Kordula", "Vikki", "Burnaby", "Teddie"];
const userList = React.useMemo(
() =>
Array.from({ length: 200 }, (_, index) => ({
id: index + 1,
first_name: firstNames[index % firstNames.length],
last_name: `Row ${index + 1}`,
age: 20 + (index % 45),
})),
[]
);
const basicColumnDef = [
{ header: "Id", accessorKey: "id", size: 80 },
{ header: "First Name", accessorKey: "first_name", size: 150 },
{ header: "Last Name", accessorKey: "last_name", size: 150 },
{ header: "Age", accessorKey: "age", size: 80 },
];
const [selectedRowIds, setSelectedRowIds] = React.useState([]);
const columns = React.useMemo(() => basicColumnDef, []);
return (
<Box className="flex flex-col gap-2">
<Text className="text-body-14">
{selectedRowIds.length > 0
? `Selected ${selectedRowIds.length} row(s): ${selectedRowIds
.slice(0, 10)
.join(", ")}${selectedRowIds.length > 10 ? "…" : ""}`
: "No rows selected"}
</Text>
<VirtualTable
columns={columns}
containerHeight={350}
data={userList}
getRowId={(row) => String(row.id)}
role="grid"
rowHeight={40}
rowSelection={{
mode: "multiple",
selectedRowIds: selectedRowIds,
onRowSelectionChange: setSelectedRowIds,
getRowLabel: (row) => `Select ${row.first_name} ${row.last_name}`,
}}
/>
</Box>
);
Column visibility
Control which columns are visible via the columnVisibility prop.
Column visibility is fully controlled using the columnVisibility prop. Pass your visibility state as columnVisibility.state, which should be an object mapping each column's ID (accessorKey or id from your column definitions) to a boolean — true to show the column, false to hide it. To respond to user changes (such as toggling columns), provide a columnVisibility.onChange callback to update your state accordingly.
Columns omitted from
statedefault to visible. WhenonChangeis omitted, the visibility is fixed to the provided state.
Note: Hiding a column re-lays out the virtualized rows immediately. Each remaining column keeps its own size, which is what holds the header and the virtualized body in alignment.
const firstNames = ["Torie", "Kordula", "Vikki", "Burnaby", "Teddie"];
const userList = React.useMemo(
() =>
Array.from({ length: 200 }, (_, index) => ({
id: index + 1,
first_name: firstNames[index % firstNames.length],
last_name: `Row ${index + 1}`,
age: 20 + (index % 45),
email: `${firstNames[index % firstNames.length].toLowerCase()}.${
index + 1
}@example.com`,
})),
[]
);
const basicColumnDef = [
{ header: "Id", accessorKey: "id", size: 80 },
{ header: "First Name", accessorKey: "first_name", size: 150 },
{ header: "Last Name", accessorKey: "last_name", size: 150 },
{ header: "Age", accessorKey: "age", size: 80 },
{ header: "Email", accessorKey: "email", size: 220 },
];
// Columns omitted default to true(visible)
const [columnVisibility, setColumnVisibility] = React.useState({
id: true,
first_name: true,
last_name: true,
age: false,
});
const toggleColumn = (columnId) =>
setColumnVisibility((prev) => ({
...prev,
[columnId]: prev[columnId] === false,
}));
const columns = React.useMemo(() => basicColumnDef, []);
// id column can't be hidden
const hideableColumns = basicColumnDef.filter(
(col) => col.accessorKey !== "id"
);
return (
<Box className="flex flex-col gap-2">
<Stack className="gap-4">
{hideableColumns.map((column) => (
<Toggle
key={column.accessorKey}
label={`Hide "${column.header}" column`}
checked={columnVisibility[column.accessorKey] === false}
onChange={() => toggleColumn(column.accessorKey)}
/>
))}
</Stack>
<VirtualTable
columns={columns}
columnVisibility={{
state: columnVisibility,
onChange: setColumnVisibility,
}}
containerHeight={350}
data={userList}
rowHeight={40}
/>
</Box>
);
Editable cells
Set editMode to render editable inputs and provide onCellValueUpdate to receive committed values.
Configure each columnDef via meta:
editable: false- opt out of edit modevalidate- return an error string to block the commit (runs on blur and on the initial value)onChange- runs on every keystroke; return a value to apply it to the cell (e.g. sanitized/filtered), or return nothing to keep the raw inputinputType- switch the input modelabel- override the accessible label
Keyboard: Enter commits the cell and advances focus to the same column in the next row. Escape reverts the cell to its original value and clears any error.
⚠️ Editable tables require
role="grid".
⚠️ Columns with their own interactivity (icon button, tooltip, links) should not be editable - set
meta.editable: false.
Note: Only the rows inside the render window exist in the DOM. An edit is committed when the cell loses focus or when its row scrolls out of the render window, so a value you type and then scroll away from is not lost.
const firstNames = ["Torie", "Kordula", "Vikki", "Burnaby", "Teddie"];
const [data, setData] = React.useState(() =>
Array.from({ length: 200 }, (_, index) => ({
id: index + 1,
first_name: firstNames[index % firstNames.length],
email: `${firstNames[index % firstNames.length].toLowerCase()}.${
index + 1
}@example.com`,
age: 20 + (index % 45),
}))
);
const columnsDef = [
// Read-only column: stays plain text even in edit mode.
{ header: "Id", accessorKey: "id", size: 80, meta: { editable: false } },
{
header: "First Name",
accessorKey: "first_name",
size: 160,
meta: {
// Blocks the commit until valid; announced to assistive tech.
validate: (value) =>
String(value).trim().length < 2 ? "Min 2 characters" : undefined,
// Strip anything that is not a letter, space, or hyphen as it is typed.
onChange: (event) => event.target.value.replace(/[^A-Za-z\s-]/g, ""),
// Accessible label override for the input.
label: "First name",
},
},
{
header: "Email",
accessorKey: "email",
size: 240,
meta: {
validate: (value) =>
String(value).includes("@") ? undefined : "Must be a valid email",
},
},
{
header: "Age",
accessorKey: "age",
size: 100,
meta: {
inputType: "number",
// Digits only.
onChange: (event) => event.target.value.replace(/\D/g, ""),
validate: (value) =>
Number(value) > 0 ? undefined : "Must be greater than 0",
},
},
];
const columns = React.useMemo(() => columnsDef, []);
return (
<VirtualTable
columns={columns}
containerHeight={350}
data={data}
editMode
getRowId={(row) => String(row.id)}
role="grid"
rowHeight={40}
onCellValueUpdate={(rowId, columnId, value) => {
setData((prev) =>
prev.map((row) =>
String(row.id) === rowId ? { ...row, [columnId]: value } : row
)
);
}}
/>
);
API Reference
VirtualTable
| Prop | Default | Description |
|---|---|---|
role | table | 'table' | 'grid'The role attribute of the table. Use 'grid' for interactive tables that have buttons, links, or other interactive elements. |
data | _ | ArrayThe dataset for the table. Each item in the array represents a row in the table and can be either an object or a primitive value. |
columns | _ | ArrayThe columns of the table. Each column is represented by a ColumnDef object, which specifies properties such as the header, accessor, and other column configurations. |
emptyTemplate? | _ | ReactNodeFallback template to render inside the table body when there is no data available. |
enableSorting? | _ | booleanSorting is enabled by default for the table. To disable sorting for the entire table, set this prop to false.Note: You can also disable sorting for specific columns by setting this in the column definition for the required columns. |
sortDescFirst? | _ | booleanFor numbers, the default sorting direction is DESC, while for strings, it is ASC. To apply the same sorting order (DESC) to strings as well, set this prop to true. This will make the first sorting direction DESC for all columns on the first click. |
rowHeight? | 48 | numberSets the Height of each row in the virtual table, in pixels. |
overScanRowCount? | 5 | numberSets the number of extra rows to render above and below the visible area or smoother scrolling performance. |
containerHeight? | 400 | numberSets the height of the container that holds the virtual table, in pixels. |
onVirtualizerReady? | _ | (virtualizer: VirtualizerUtils) => void;Callback function that is triggered when the virtualizer instance is ready. This provides access to the MinimalVirtualizer instance, which includes the scrollToIndex method for programmatic scrolling to a specific row. This is useful for implementing features like "scroll to top" or "scroll to bottom". |
rowSelection? | _ | objectConfiguration to control row selection behavior of the table. The VirtualTable is purely controlled — the consumer owns selection state via selectedRowIds. Selection is keyed by the ID from getRowId, so it survives sorting and scrolling even though only the visible rows exist in the DOM. See Row selection configuration section below for detailed properties. |
getRowId? | _ | (row: TData) => stringMaps each row's data object to a stable string ID. Required in practice alongside rowSelection and onCellValueUpdate — without it, the row's array index is used ('0', '1', …), which shifts when the data is sorted or reordered. |
columnVisibility? | _ | { state: Record<string, boolean>; onChange?: (visibility: Record<string, boolean>) => void }Configuration to control which columns are visible. The consumer owns the visibility state via state. |
columnVisibility.state | _ | Record<string, boolean>A map of column ID to a boolean indicating each column's visibility ( true shows the column, false hides it). Column IDs are the accessorKey (or id) from the column definitions. Columns omitted from the map default to visible. |
columnVisibility.onChange? | _ | (visibility: Record<string, boolean>) => voidCalled when the column visibility state changes. When omitted, the visibility is fixed to the provided state. |
editMode? | false | booleanWhen true, every eligible cell renders as an editable input. Opt a column out with meta.editable: false in its ColumnDef. Requires role="grid" for accessibility (the VirtualTable warns in development when used with role="table"). Per-column options are the same as the <Table /> component's — see the Editable column configuration section in the Table documentation. |
onCellValueUpdate? | _ | (rowId: string, columnId: string, value: string) => voidCalled when a cell value changes and passes validation (on blur). Receives the row ID, column ID, and the new value. The value is always a string (number columns commit strings too), so consumers coerce it themselves — e.g. Number(value). The consumer owns the data and is responsible for updating it. Because only the visible rows are mounted, an edit is also committed when its row scrolls out of the render window. An invalid edit can't be committed, so it is kept instead and shown with its error when the row scrolls back into view, until it is fixed or reverted with Escape. |
isLoading? | false | booleanShows a centred loading indicator filling the scroll viewport below the header. Useful for server-side data fetching scenarios to maintain visual stability. |
Row selection configuration
When the rowSelection prop is provided, it accepts an object with the following properties.
| Prop | Default | Description |
|---|---|---|
mode | _ | 'multiple'The mode of row selection. VirtualTable supports 'multiple' only — unlike Table, there is no single-selection mode. It renders a leftmost checkbox column with a select-all header checkbox. The injected checkbox column is pre-sized, so it needs no size in your column definitions. |
selectedRowIds | _ | string[]The IDs of the currently selected rows. Each value must match the string returned by getRowId for its row. This prop is the source of truth — update it to change the selection. |
onRowSelectionChange | _ | (selectedRowIds: string[]) => voidCalled when the user toggles a row checkbox or the select-all header checkbox. Receives the full new array of selected row IDs. Because there is no pagination, the header checkbox selects every row in the table. |
selectAllLabel? | _ | stringAccessible label for the select-all header checkbox. Defaults to the multiSelectAllButtonLabel translation ('Select all rows' in English). |
getRowLabel? | _ | (row: TData) => stringReturns the accessible label for a row's checkbox — use it for meaningful names like "Select Jane Doe". Defaults to the multiSelectButtonLabel translation followed by the row's position in data, starting at 1 ('Select row 1', 'Select row 2', … in English). |
Style API
Our design system components include style props that allow you to easily customize different parts of each component to match your design needs.
Please refer to the Style API documentation for more insights.
VirtualTable parts
const [data, setData] = React.useState([]);
const [loading, setLoading] = React.useState(true);
const basicColumnDef = [
{
header: "FirstName",
accessorKey: "name.first",
size: 150,
},
{
header: "LastName",
accessorKey: "name.last",
size: 150,
},
{
header: "Age",
accessorKey: "dob.age",
size: 80,
},
{
header: "Phone",
accessorKey: "phone",
size: 200,
},
{
header: "Country",
accessorKey: "location.country",
size: 150,
},
];
React.useEffect(() => {
fetch("https://randomuser.me/api/?results=200")
.then((response) => response.json())
.then((data) => {
setLoading(false);
})
.catch((error) => {
console.log(error);
setLoading(false);
});
}, []);
const columns = React.useMemo(() => basicColumnDef, []);
if (loading) {
return (
<Box className="flex justify-center w-full p-4 h-[200px]">
<Loading size="48" />
</Box>
);
}
return (
<Box className="flex align-center justify-center">
<VirtualTable
classNames={{
tableContainer: "max-h-96 overflow-auto",
table: "bg-caution-secondary",
// Styles the table header
header: "sticky top-0 z-10",
headerRow: "bg-gray-400 ",
headerCell: "bg-palette-violet-background",
headerContentWrapper: "p-1 pl-2 bg-palette-blue-background-active",
headerContent: "font-stronger text-constant-white",
sortIconsWrapper: "bg-accent-secondary border-2",
sortIcon: "fill-positive",
// Styles the table body row
row: "border-input-active",
cell: "font-stronger text-caution-secondary",
}}
columns={columns}
data={data}
rowHeight={40}
containerHeight={350}
overscanRowCount={8}
/>
</Box>
);
| Stylable Parts | Description |
|---|---|
| tableContainer | Wrapper that holds the entire table, ensuring proper layout, scrolling, and responsiveness |
| table | Component for displaying data in rows and columns with a consistent design and interactions |
| header | Section that contains the header row with all header cells, defining column titles and interactions. |
| headerRow | Contains all header cells, defining column titles, sorting, actions etc., |
| headerCell | Container that holds header content and aligns with the table’s structure. |
| headerContentWrapper | Container that holds the column header content, ensuring proper alignment, spacing, and interaction |
| headerContent | Main content inside a column header, typically including text, icons, or actions |
| sortIconsWrapper | Container that holds sorting icons. |
| sortIcon | Visually indicate and enable sorting of column data. |
| body | The main section that holds the data rows. It follows structured spacing, typography. |
| row | Row represents a single record or data entry, structured across column. It typically includes text, icons etc., |
| cell | Cell is a single unit of content within a row and column. |
| loadingCell | The <td> that spans every column while isLoading is true. |
| loadingContainer | Centres the loading spinner and fills the scroll viewport below the header. |
Editable cell parts
The editable cell exposes its own stylable parts, available only when editMode is on. Use editableCellWrapper to style the editable <td>, editableCell for the wrapper around the input, editableInput for the input itself, editableInputFocusContainer and editableInputFocusIndicator for the input's background and border (including hover/focus).
The first row below starts with an invalid First Name (a single character), so its cell renders in the error state on load — letting you see the error styling without interacting.
Id | First Name | Last Name | Age |
|---|
const [data, setData] = React.useState([
{ id: 1, first_name: "V", last_name: "Rustman", age: 45 },
{ id: 2, first_name: "Kordula", last_name: "Gecks", age: 32 },
{ id: 3, first_name: "Vikki", last_name: "Simoens", age: 28 },
]);
const basicColumnDef = [
{ header: "Id", accessorKey: "id", size: 80, meta: { editable: false } },
{
header: "First Name",
accessorKey: "first_name",
size: 160,
meta: {
validate: (value) =>
String(value).trim().length < 2 ? "Min 2 characters" : undefined,
},
},
{ header: "Last Name", accessorKey: "last_name", size: 160 },
{ header: "Age", accessorKey: "age", size: 100, meta: { inputType: "number" } },
];
const columns = React.useMemo(() => basicColumnDef, []);
return (
<VirtualTable
classNames={{
editableCellWrapper: "bg-palette-blue-background",
editableInput: "font-stronger text-accent",
editableInputFocusIndicator: "group-hover:border-accent",
}}
columns={columns}
containerHeight={200}
data={data}
editMode
enableSorting={false}
getRowId={(row) => String(row.id)}
role="grid"
rowHeight={40}
onCellValueUpdate={(rowId, columnId, value) => {
setData((prev) =>
prev.map((row) =>
String(row.id) === rowId ? { ...row, [columnId]: value } : row
)
);
}}
/>
);
| Stylable Parts | Description |
|---|---|
| editableCellWrapper | The <td> element for an editable cell (full row height, no padding so the input fills the cell). |
| editableCell | Wrapper around the input rendered inside an editable cell. |
| editableInput | The text input rendered inside an editable cell. |
| editableInputFocusContainer | The input's focus container — controls background and corner radius. |
| editableInputFocusIndicator | The input's border indicator — controls the border shown on hover and focus. |