@gravity-ui/table
September 8, 2026 · View on GitHub
Install
npm install --save @gravity-ui/table
Usage
import React from 'react';
import {Table, useTable} from '@gravity-ui/table';
import type {ColumnDef} from '@gravity-ui/table/tanstack';
interface Person {
id: string;
name: string;
age: number;
}
const columns: ColumnDef<Person>[] = [
{accessorKey: 'name', header: 'Name', size: 100},
{accessorKey: 'age', header: 'Age', size: 100},
];
const data: Person[] = [
{id: 'name', name: 'John', age: 23},
{id: 'age', name: 'Michael', age: 27},
];
const BasicExample = () => {
const table = useTable({
columns,
data,
});
return <Table table={table} />;
};
Components
There are two Table components that you can use:
BaseTable- a component with basic styles only;Table- a component with Gravity UI based styles.
Row selection
import {selectionColumn} from '@gravity-ui/table';
import type {RowSelectionState} from '@gravity-ui/table/tanstack';
const columns: ColumnDef<Person>[] = [
selectionColumn as ColumnDef<Person>,
// ...other columns
];
const data: Person[] = [
/* ... */
];
const RowSelectionExample = () => {
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({});
const table = useTable({
columns,
data,
enableRowSelection: true,
enableMultiRowSelection: true,
onRowSelectionChange: setRowSelection,
state: {
rowSelection,
},
});
return <Table table={table} />;
};
To use grouping with selection, use useRowSelectionFixedHandler hook. Without it parent row checkbox state will be wrong. https://github.com/TanStack/table/issues/4878
Custom Ranged Selection Column
The useToggleRangeSelectionHandler hook returns a change handler that listens for Shift+click events and performs ranged row selection. It needs to be passed a CellContext instance in order to have access to the table's and row's internal states.
import React, {type ChangeEvent, useCallback, useState} from 'react';
import {Table, useToggleRangeSelectionHandler, useTable} from '@gravity-ui/table';
import type {CellContext, ColumnDef, RowSelectionState} from '@gravity-ui/table/tanstack';
import {Checkbox, type CheckboxProps} from '@gravity-ui/uikit';
type CustomRangedSelectionCheckboxProps = Omit<CheckboxProps, 'onChange'> & {
cellContext: CellContext<unknown, unknown>;
};
const CustomRangedSelectionCheckbox = ({
className,
cellContext,
...restProps
}: CustomRangedSelectionCheckboxProps) => {
const rowToggleRangedSelectionHandler = useToggleRangeSelectionHandler(cellContext);
const handleChange = useCallback(
(event: ChangeEvent<HTMLInputElement>): void => {
rowToggleRangedSelectionHandler(event);
},
[rowToggleRangedSelectionHandler],
);
return <Checkbox {...restProps} onChange={handleChange} />;
};
const customSelectionColumn: ColumnDef<unknown> = {
id: '_select',
header: ({table}) => (
<Checkbox
size="l"
checked={table.getIsAllRowsSelected()}
indeterminate={table.getIsSomeRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
),
cell: (cellContext) => (
<CustomRangedSelectionCheckbox
size="l"
checked={cellContext.row.getIsSelected()}
disabled={!cellContext.row.getCanSelect()}
indeterminate={cellContext.row.getIsSomeSelected()}
cellContext={cellContext}
/>
),
size: 41,
maxSize: 41,
minSize: 41,
enableResizing: false,
enableSorting: false,
};
const columns: ColumnDef<Person>[] = [
customSelectionColumn as ColumnDef<Person>,
// ...other columns
];
const data: Person[] = [
/* ... */
];
const RowRangedSelectionExample = () => {
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const table = useTable({
columns,
data,
enableRowSelection: true,
enableMultiRowSelection: true,
onRowSelectionChange: setRowSelection,
state: {
rowSelection,
},
});
return <Table table={table} />;
};
There is also a RangedSelectionCheckbox component, which internally uses the hook and accepts a CellContext instance as a prop. This component provides a shorthand for adding ranged selection functionality to custom selection columns.
import type {ColumnDef} from '@gravity-ui/table/tanstack';
import {RangedSelectionCheckbox, SelectionCheckbox} from '@gravity-ui/table';
export const selectionColumn: ColumnDef<unknown> = {
id: '_select',
header: ({table}) => (
<SelectionCheckbox
checked={table.getIsAllRowsSelected()}
disabled={!table.options.enableRowSelection}
indeterminate={table.getIsSomeRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
),
cell: (cellContext) => (
<RangedSelectionCheckbox
checked={cellContext.row.getIsSelected()}
disabled={!cellContext.row.getCanSelect()}
indeterminate={cellContext.row.getIsSomeSelected()}
cellContext={cellContext}
/>
),
meta: {
hideInSettings: true,
},
size: 32,
minSize: 32,
};
By default, the selection column generated with selectionColumn includes ranged selection functionality.
import {selectionColumn} from '@gravity-ui/table';
import type {ColumnDef} from '@gravity-ui/table/tanstack';
const columns: ColumnDef<Person>[] = [
selectionColumn as ColumnDef<Person>,
// ...other columns
];
Note: If the table contains nested rows, range selection will not work. At the moment, this is considered undefined behavior.
Sorting
Learn about the column properties in the react-table docs
import type {SortingState} from '@gravity-ui/table/tanstack';
const columns: ColumnDef<Person>[] = [
/* ... */
];
const data: Person[] = [
/* ... */
];
const SortingExample = () => {
const [sorting, setSorting] = React.useState<SortingState>([]);
// Your column MUST have accessorFn for sorting to be enabled
const table = useTable({
columns,
data,
enableSorting: true,
getRowId: (item) => item.id,
onSortingChange: setSorting,
state: {
sorting,
},
});
return <Table table={table} />;
};
If you want to sort the elements manually pass manualSorting property:
const table = useTable({
// ...
manualSorting: true,
});
Grouping
import type {ExpandedState, Row} from '@gravity-ui/table/tanstack';
interface Person {
id: string;
name: string;
age: number;
}
interface PersonGroup {
id: string;
name: string;
items: Person[];
}
type Item = PersonGroup | Person;
const columns: ColumnDef<Item>[] = [
{accessorKey: 'name', header: 'Name', size: 200},
{accessorKey: 'age', header: 'Age', size: 100},
];
const data: Item[] = [
{
id: 'friends',
name: 'Friends',
items: [
{id: 'nick', name: 'Nick', age: 25},
{id: 'tom', name: 'Tom', age: 21},
],
},
{
id: 'relatives',
name: 'Relatives',
items: [
{id: 'john', name: 'John', age: 23},
{id: 'michael', name: 'Michael', age: 27},
],
},
];
const getGroupTitle = (row: Row<Item>) => row.getValue<string>('name');
const GroupingExample = () => {
const [expanded, setExpanded] = React.useState<ExpandedState>({});
const table = useTable({
columns,
data,
enableExpanding: true,
getSubRows: (item) => ('items' in item ? item.items : undefined),
onExpandedChange: setExpanded,
state: {
expanded,
},
});
return <Table table={table} getGroupTitle={getGroupTitle} />;
};
To use grouping with selection, use useRowSelectionFixedHandler hook. Without it parent row checkbox state will be wrong. https://github.com/TanStack/table/issues/4878
To enable nesting styles, pass withNestingStyles = true in the column configuration.
Nesting indicators can be disabled by passing showTreeDepthIndicators = false.
To add a control for expanding/collapsing rows, wrap the cell content with the TreeExpandableCell component or with your similar custom component:
import {TreeExpandableCell} from '@gravity-ui/table';
const columns: ColumnDef<Item>[] = [
{
accessorKey: 'name',
header: 'Name',
size: 200,
showTreeDepthIndicators: false,
withNestingStyles: true,
cell: ({row, info}) => (
<TreeExpandableCell row={row}>{info.getValue<string>()}</TreeExpandableCell>
),
},
// ...other columns
];
Reordering
import type {ReorderingProviderProps} from '@gravity-ui/table';
import {dragHandleColumn, ReorderingProvider} from '@gravity-ui/table';
const columns: ColumnDef<Person>[] = [
dragHandleColumn,
// ...other columns
];
const data: Person[] = [
/* ... */
];
const ReorderingExample = () => {
const table = useTable({
columns,
data,
getRowId: (item) => item.id,
});
const handleReorder = React.useCallback<
NonNullable<ReorderingProviderProps<Person>['onReorder']>
>(
({
draggedItemKey,
targetItemKey,
baseItemKey,
baseNextItemKey,
enableNesting,
nextChild,
pullFromParent,
}) => {
// ...
},
[],
);
return (
<ReorderingProvider table={table} onReorder={handleReorder}>
<Table table={table} />
</ReorderingProvider>
);
};
Reordering without a drag handle
Set dragWithoutHandle to use the whole row as the drag activator and omit
dragHandleColumn from the column definitions:
const columns: ColumnDef<Person>[] = [
{accessorKey: 'name', header: 'Name'},
{accessorKey: 'age', header: 'Age'},
];
return (
<ReorderingProvider table={table} dragWithoutHandle onReorder={handleReorder}>
<Table table={table} />
</ReorderingProvider>
);
The pointer must move by 8 pixels before dragging starts, so regular row and control clicks keep
working. To exclude a custom part of a row from starting a drag, call preventDefault() in its
onPointerDown handler.
ReorderingProvider enables dnd-kit's vertical auto-scroll by default. Pass autoScroll={false}
when the application supplies its own drag auto-scroll implementation; running both at once can
produce competing scroll writes.
Column reordering
Wrap the table with ColumnReorderingProvider to enable drag-and-drop reordering of columns by their headers.
import {ColumnReorderingProvider} from '@gravity-ui/table';
const columns: ColumnDef<Person>[] = [
{accessorKey: 'name', header: 'Name', size: 100},
{accessorKey: 'age', header: 'Age', size: 100},
];
const ColumnReorderingExample = () => {
const table = useTable({
columns,
data,
getRowId: (item) => item.id,
});
return (
<ColumnReorderingProvider table={table}>
<Table table={table} />
</ColumnReorderingProvider>
);
};
Row and column reordering together
Nest ColumnReorderingProvider and ReorderingProvider to enable both drag axes at once. The order of providers does not matter — they share a single dnd-kit context internally.
import type {ColumnReorderingProviderProps, ReorderingProviderProps} from '@gravity-ui/table';
import {ColumnReorderingProvider, ReorderingProvider, dragHandleColumn} from '@gravity-ui/table';
const columns: ColumnDef<Person>[] = [
dragHandleColumn,
{accessorKey: 'name', header: 'Name'},
{accessorKey: 'age', header: 'Age'},
];
const CombinedReorderingExample = () => {
const [data, setData] = React.useState(initialData);
const [columnOrder, setColumnOrder] = React.useState<string[]>([]);
const table = useTable({
columns,
data,
getRowId: (item) => item.id,
state: {columnOrder},
onColumnOrderChange: setColumnOrder,
});
const handleRowReorder = React.useCallback<
NonNullable<ReorderingProviderProps<Person>['onReorder']>
>(({draggedItemKey, baseItemKey}) => {
// update data array
}, []);
const handleColumnReorder = React.useCallback<
NonNullable<ColumnReorderingProviderProps<Person>['onReorder']>
>(({columnOrder}) => {
setColumnOrder(columnOrder);
}, []);
return (
<ColumnReorderingProvider table={table} onReorder={handleColumnReorder}>
<ReorderingProvider table={table} onReorder={handleRowReorder}>
<Table table={table} />
</ReorderingProvider>
</ColumnReorderingProvider>
);
};
If you control columnOrder yourself (e.g. to persist it), pass onReorder and apply the resulting order:
const [columnOrder, setColumnOrder] = React.useState<string[]>([]);
const table = useTable({
columns,
data,
state: {columnOrder},
onColumnOrderChange: setColumnOrder,
});
return (
<ColumnReorderingProvider
table={table}
onReorder={({columnOrder}) => setColumnOrder(columnOrder)}
>
<Table table={table} />
</ColumnReorderingProvider>
);
CSS API:
| CSS variable | Default | Description |
|---|---|---|
--gt-table-reordering-insertion-line-color | #4d8bff | Color of the drop insertion line |
--gt-table-reordering-insertion-line-width | 2px | Width of the drop insertion line |
--gt-table-reordering-dragged-opacity | 0.4 | Opacity of the dragged column |
--gt-table-drag-overlay-background | #fff | Drag preview background |
--gt-table-drag-overlay-shadow | 0 3px 12px rgba(0,0,0,0.15) | Drag preview box-shadow |
--gt-table-drag-overlay-border-radius | 6px | Drag preview border radius |
To forbid reordering a specific column, set enableColumnReordering: false in its column definition. Placeholder (grouped) columns are not draggable. Use activationDistance (default 8) to tune how far the pointer must move before a drag starts, which keeps header clicks (like sorting) working.
Pinned columns can be reordered too, but only among themselves: a column can be moved within the left-pinned group, the right-pinned group, or the center (non-pinned) group — it never crosses a pin boundary by dragging.
<ColumnReorderingProvider
table={table}
onReorder={({columnOrder, columnPinning, pinned}) => {
if (pinned) {
setColumnPinning(columnPinning);
} else {
setColumnOrder(columnOrder);
}
}}
>
<Table table={table} />
</ColumnReorderingProvider>
While dragging:
- a floating preview of the column (its header plus the first rows) follows the pointer in a drag overlay;
- the dragged column becomes semi-transparent;
- a blue insertion line is drawn where the column will be dropped;
<ColumnReorderingProvider
table={table}
autoScroll
dragOverlayRowCount={10}
renderDragOverlay={({columnId}) => <CustomColumnPreview columnId={columnId} />}
>
<Table table={table} />
</ColumnReorderingProvider>
Virtualization
Use if you want to use grid container as the scroll element (if you want to use window see window virtualization section). Be sure to set a fixed height on the container; otherwise, virtualization will not work.
import {useRowVirtualizer} from '@gravity-ui/table';
const columns: ColumnDef<Person>[] = [
/* ... */
];
const data: Person[] = [
/* ... */
];
const VirtualizationExample = () => {
const table = useTable({
columns,
data,
getRowId: (item) => item.id,
});
const containerRef = React.useRef<HTMLDivElement>(null);
const rowVirtualizer = useRowVirtualizer({
count: table.getRowModel().rows.length,
estimateSize: () => 20,
overscan: 5,
getScrollElement: () => containerRef.current,
});
return (
<div ref={containerRef} style={{height: '500px', overflow: 'auto'}}>
<Table table={table} rowVirtualizer={rowVirtualizer} />
</div>
);
};
For tables that must stay covered during continuous bidirectional scrolling, enable the adaptive direct-DOM mode and use stable semantic keys. The same key must identify a row before and after an immutable reorder or a tree reparent:
const rows = table.getRowModel().rows;
const getItemKey = React.useCallback(
(index: number) => rows[index]?.id ?? `missing:${index}`,
[rows],
);
const rowVirtualizer = useRowVirtualizer({
adaptiveFlushSync: true,
count: rows.length,
directDomUpdates: true,
directDomUpdatesMode: 'position',
estimateSize: () => 40,
getItemKey,
getScrollElement: () => containerRef.current,
overscan: 12,
});
adaptiveFlushSync keeps a warm mounted range and synchronously fills only an uncovered visible
range. directDomUpdates lets the virtualizer update row positions and the body size without a
React render for every scroll notification. Use position for table rows unless transforms are
required by the surrounding layout. Keep getRowId and getItemKey stable, and set count from
the current flattened row model after expanding, collapsing, reordering, or reparenting rows.
An arbitrary custom rangeExtractor remains authoritative and disables adaptive window planning.
BaseTable.canDeferOffscreenCellContent is an additional opt-in for very wide adaptive tables.
Return true only for passive cell content whose delayed mount cannot change geometry, focus,
accessibility, or application state. Custom rows, group rows, pinned cells, and interactive or
side-effectful content should remain eager.
With the default adaptive range extractor, BaseTable pins the active dragged row automatically.
A custom range extractor is responsible for retaining that row itself. If you use reordering with
a non-adaptive virtualizer, keep the same semantic getItemKey contract and pass the
rangeExtractor option:
import {getVirtualRowRangeExtractor} from '@gravity-ui/table';
// ...
const tableRef = React.useRef<HTMLTableElement>(null);
const rowVirtualizer = useRowVirtualizer({
// ...
rangeExtractor: getVirtualRowRangeExtractor(tableRef.current),
});
return (
<TableWithReordering
ref={tableRef}
table={table}
rowVirtualizer={rowVirtualizer}
onReorder={handleReorder}
/>
);
Window virtualization
Use if you want to use window as the scroll element
import {useWindowRowVirtualizer} from '@gravity-ui/table';
const columns: ColumnDef<Person>[] = [
/* ... */
];
const data: Person[] = [
/* ... */
];
const WindowVirtualizationExample = () => {
const table = useTable({
columns,
data,
getRowId: (item) => item.id,
});
const bodyRef = React.useRef<HTMLTableSectionElement>(null);
const rowVirtualizer = useWindowRowVirtualizer({
count: table.getRowModel().rows.length,
estimateSize: () => 20,
overscan: 5,
scrollMargin: bodyRef.current?.offsetTop ?? 0,
});
return <Table table={table} rowVirtualizer={rowVirtualizer} bodyRef={bodyRef} />;
};
Resizing
const columns: ColumnDef<Person>[] = [
/* ... */
];
const data: Person[] = [
/* ... */
];
const ResizingDemo = () => {
const table = useTable({
columns,
data,
enableColumnResizing: true,
columnResizeMode: 'onChange',
});
return <Table table={table} />;
};
Column settings
const columns: ColumnDef<Person>[] = [
// ...other columns
{
id: 'settings_column_id',
header: ({table}) => <TableSettings table={table} />,
meta: {
hideInSettings: false, // Optional. Allows to hide this column from settings popover
titleInSettings: 'ReactNode', // Optional. Overrides header field for settings popover (if you need different content for header and settings popover)
},
}, // or you can use function getSettingsColumn
];
const data: Person[] = [
/* ... */
];
const TableSettingsDemo = () => {
const [columnVisibility, onColumnVisibilityChange] = React.useState<VisibilityState>({
// for outside control and initial state
column_id: false, // for hidding by default
});
const [columnOrder, onColumnOrderChange] = React.useState<string[]>([
/* leaf columns ids */
]); // for outside control and initial state
// Alternative variant to get state, callbacks, and set on setting apply callbacks - using useTableSettings hook:
// const {state, callbacks} = useTableSettings({initialVisibility: {}, initialOrder: []})
const table = useTable({
columns,
data,
state: {
columnVisibility,
columnOrder,
},
onColumnVisibilityChange,
onColumnOrderChange,
});
return <Table table={table} />;
};
Learn more about the table and the column resizing properties in the react-table docs
Known Issues and Compatibility
React 19 + React Compiler Compatibility
⚠️ Known Issue: There is a known compatibility issue with React 19 and React Compiler when using @gravity-ui/table (which is built on top of TanStack Table). The table may not re-render when data changes. See TanStack Table issue #5567 for details.
Workaround:
If you're using React 19 with React Compiler and experiencing issues with table re-rendering, you can use the 'use no memo' directive in your component code:
import React from 'react';
import {Table, useTable} from '@gravity-ui/table';
import type {ColumnDef} from '@gravity-ui/table/tanstack';
function MyTable() {
'use no memo'; // Disable React Compiler memoization for this component
const [data, setData] = React.useState<Person[]>([]);
const table = useTable({
data,
columns,
});
return <Table table={table} />;
}
Alternative Solution:
You can also explicitly memoize the table instance or data to ensure proper re-renders:
import React from 'react';
import {Table, useTable} from '@gravity-ui/table';
import type {ColumnDef} from '@gravity-ui/table/tanstack';
function MyTable() {
const [data, setData] = React.useState<Person[]>([]);
// Explicitly memoize data to ensure re-renders
const memoizedData = React.useMemo(() => data, [data]);
const table = useTable({
data: memoizedData,
columns,
});
return <Table table={table} />;
}
Note: This issue is in the underlying TanStack Table library and will need to be fixed there. The workarounds above should help until a fix is available.
License
Distributed under the MIT License. See LICENSE for details.
For AI agents
A headless, TanStack-Table-powered data grid for Gravity UI apps — reach for it for sortable, selectable, groupable, reorderable, and virtualized tables instead of composing raw markup on top of uikit's basic Table.
When to use
- Large datasets that need row or window virtualization (
useRowVirtualizer,useWindowRowVirtualizer). - Column sorting, resizing, reordering (
ColumnReorderingProvider), pinning, and per-user column settings (TableSettings). - Row selection (single/multi, ranged) and tree/grouped rows with expandable cells.
When not to use
- A simple, static table with a handful of rows and no advanced features — uikit's built-in
Tablefrom@gravity-ui/uikitis lighter. - A non-tabular list — use
Listfrom@gravity-ui/uikit. - Spreadsheet-style inline cell editing — this grid is read/display-focused, not an editable spreadsheet.
Common pitfalls
- You build the table with
useTable, then render<Table table={table} />. The main prop istable(the instance), notdata/columnsdirectly on<Table>; passdataandcolumnstouseTable. - Types come from the
@gravity-ui/table/tanstacksubpath. ImportColumnDef,RowSelectionState,SortingState, etc. from@gravity-ui/table/tanstack, not from the package root. - Sorting needs an accessor. A column must have
accessorKey/accessorFnfor sorting to work; setenableSortingand providegetRowId. - React 19 + React Compiler can skip re-renders. This is an upstream TanStack Table issue — add the
'use no memo'directive to the component or memoizedata. - Range selection breaks with nested rows. Ranged selection is undefined behavior when the table has grouped/nested rows; use
useRowSelectionFixedHandlerfor correct parent-checkbox state with grouping.
Documentation for AI agents
Agent-readable documentation for the installed version is located in node_modules/@gravity-ui/table/build/docs/INDEX.md.