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 getRowId is used alongside rowSelection.selectedRowIds to 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.

No rows selected
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 state default to visible. When onChange is 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 mode
  • validate - 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 input
  • inputType - switch the input mode
  • label - 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

PropDefaultDescription
roletable'table' | 'grid'
The role attribute of the table. Use 'grid' for interactive tables that have buttons, links, or other interactive elements.
data_Array
The 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_Array
The 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?_ReactNode
Fallback template to render inside the table body when there is no data available.
enableSorting?_boolean
Sorting 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?_boolean
For 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?48number
Sets the Height of each row in the virtual table, in pixels.
overScanRowCount?5number
Sets the number of extra rows to render above and below the visible area or smoother scrolling performance.
containerHeight?400number
Sets 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?_object
Configuration 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) => string
Maps 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>) => void
Called when the column visibility state changes. When omitted, the visibility is fixed to the provided state.
editMode?falseboolean
When 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) => void
Called 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?falseboolean
Shows 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.

PropDefaultDescription
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[]) => void
Called 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?_string
Accessible label for the select-all header checkbox. Defaults to the multiSelectAllButtonLabel translation ('Select all rows' in English).
getRowLabel?_(row: TData) => string
Returns 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 PartsDescription
tableContainerWrapper that holds the entire table, ensuring proper layout, scrolling, and responsiveness
tableComponent for displaying data in rows and columns with a consistent design and interactions
headerSection that contains the header row with all header cells, defining column titles and interactions.
headerRowContains all header cells, defining column titles, sorting, actions etc.,
headerCellContainer that holds header content and aligns with the table’s structure.
headerContentWrapperContainer that holds the column header content, ensuring proper alignment, spacing, and interaction
headerContentMain content inside a column header, typically including text, icons, or actions
sortIconsWrapperContainer that holds sorting icons.
sortIconVisually indicate and enable sorting of column data.
bodyThe main section that holds the data rows. It follows structured spacing, typography.
rowRow represents a single record or data entry, structured across column. It typically includes text, icons etc.,
cellCell is a single unit of content within a row and column.
loadingCellThe <td> that spans every column while isLoading is true.
loadingContainerCentres 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 PartsDescription
editableCellWrapperThe <td> element for an editable cell (full row height, no padding so the input fills the cell).
editableCellWrapper around the input rendered inside an editable cell.
editableInputThe text input rendered inside an editable cell.
editableInputFocusContainerThe input's focus container — controls background and corner radius.
editableInputFocusIndicatorThe input's border indicator — controls the border shown on hover and focus.