Table
The grid for many records of one shape — reach for a Card grid when a row reads better as an object than as a set of columns, and for Attribute when you are showing one record rather than many. Everything interactive is controlled: sorting, selection, expansion, column order, pinning, sizing and visibility are each a state prop with a callback, so nothing moves unless you move it, and manualSorting hands the sort to the server. Filtering belongs above the grid in a FilterInput, and a long collection scrolls rather than paginates — Pagination is the deliberate exception, for when a page number is part of the task.
Source · Storybook
Usage
import { Table, type TableSortingState } from '@wallarm-org/design-system/Table';
Examples
No stretch
stretch={false} — columns keep their defined widths and do not expand to fill the container.
Empty space appears on the right when the total column width is less than the container width.
useClientPagination slices the rows and the pager sits right-aligned below the table,
sharing its footer with the rows-per-page control.
Sorting
Sorting held in state, so the arrow and the row order are the same fact — click a header to
cycle through the directions.
Manual sorting
manualSorting stops the table sorting anything itself: it only reports the change, and the
story re-derives data the way a refetch would.
With analytics
Every analytics seam at once — TableSortTrigger, TableColumnMenu and each of its items,
the action bar, the scroll handlers — plus the state callbacks, which carry the resulting
sort, selection and column layout rather than just a click.
Loading state
isLoading with nothing to show yet: skeleton rows under a real header, so the columns do
not jump when the data lands.
Loading with data
The same flag with rows already present appends the skeletons below them — this is the shape
for fetching the next batch, not for replacing what is on screen.
Empty collection
TableEmptyState holding an EmptyState of type='collection-empty': nothing exists yet,
so the action creates the first thing.
No results
When data exists but the current filters or search matched nothing, stay at
the collection-empty scale — a table body is a page-level surface, and
no-results is the compact treatment for a card or menu. What changes is the
action: a neutral one that clears the filters, never a primary "create".
Column resizing
Drag a header edge to resize. columnSizing is state like everything else, so a width can
be remembered between visits.
Column resizing with overflow list
A resizable column whose cell holds an OverflowList: the tags re-measure as the column
narrows instead of being clipped by it.
Column resizing with pinning
Resizing alongside a pinned column — the pinned one holds its position while the rest of the
row moves underneath it.
Column pinning
columnPinning freezes a column to the left, so the name of the thing stays readable
however far sideways the table scrolls.
Column drag and drop
Drag a header to reorder the columns. columnOrder is the same state the settings menu
writes, so the two never disagree.
Column header menu
The per-column menu that appears on hover — sort, pin, hide, move — which is where the
column controls live once a header is too narrow to show them.
Row selection
Checkboxes plus TableActionBar, which rises from the bottom once a row is ticked and
carries the verbs that apply to the whole selection.
Select all rows gesture
onSelectAllRows separates the master checkbox from a hand-made full selection, which the
row state alone cannot tell apart. Here the gesture means "every row matching the filter",
including the ones the server has not sent — so the bar jumps to the filter's total, and any
manual change drops back to the rows actually loaded.
Select all rows disabled
enableSelectAllRows={false} drops the master checkbox while keeping the column, for a
listing where "all rows" is not a scope the consumer can act on. Per-row checkboxes stay,
shift-click range selection included.
A selection that outlives the table scrolling away: under virtualized='window' the bar is
pinned to the viewport rather than to the table.
import { useMemo, useState } from 'react';
import { Button } from '@wallarm-org/design-system/Button';
import { Copy, Trash2 } from '@wallarm-org/design-system/icons';
import { Table, TableActionBar, type TableRowSelectionState } from '@wallarm-org/design-system/Table';
import { createLargeSecurityEvents, securityColumns } from './mocks';
export const RowSelectionWindowScroll = () => {
const largeData = useMemo(() => createLargeSecurityEvents(100), []);
const [rowSelection, setRowSelection] = useState<TableRowSelectionState>({});
return (
<>
<Table
data-testid='row-selection-window-scroll-table'
data={largeData}
columns={securityColumns}
getRowId={row => row.id}
virtualized='window'
rowSelection={rowSelection}
onRowSelectionChange={setRowSelection}
>
<TableActionBar>
<Button variant='ghost' color='neutral-alt' onClick={onDuplicate}>
<Copy /> Duplicate
</Button>
<Button color='brand' onClick={onDelete}>
<Trash2 /> Delete
</Button>
</TableActionBar>
</Table>
{/* Filler so the e2e suite can scroll the table fully out of view while a
selection stays active — this story's only content otherwise fills
exactly to the table's own height, so max scroll would always leave
the table's tail in view. Generous margin over the viewport height
(800px in the e2e config) so the anchor is unambiguously out of view
at max scroll, not just mostly. */}
<div style={{ height: 3000 }} />
</>
);
};
Open in Storybook
Row grouping
getSubRows turns flat data into groups — each group row counts its children and collapses
them, keyed in the same expanded state a row detail uses.
Row expanding
renderExpandedRow draws a panel beneath the row, for the two or three fields that do not
deserve columns of their own.
Right-click inside a cell: DropdownMenuContextTrigger puts that column's verbs where the
pointer already is, so each column can offer its own.
The gear at the end of the header — search the columns, show and hide them, reorder them,
reset — writing the same visibility and order state as the header menu.
deferColumnVisibility holds switch toggles and "Reset to default" while the
settings menu is open and applies them as one columnVisibility change on
close — for consumers that fetch a column's data only once the menu closes,
so a new column never sits empty behind the open menu.
columnGroups turns the settings menu into labeled, non-reorderable sections
(the FilterInput field-menu pattern): no drag handle, columns rendered under
their group header in the given order. Column ids absent from every group
(cweId here) fall into a trailing headerless section.
Virtualization
virtualized='container' inside a fixed height renders only the rows in view, which is what
lets a thousand rows behave like ten.
import { useMemo } from 'react';
import { Table } from '@wallarm-org/design-system/Table';
import { createLargeSecurityEvents, securityColumns } from './mocks';
export const Virtualization = () => {
const largeData = useMemo(() => createLargeSecurityEvents(1000), []);
return (
<Table
className='h-500'
data={largeData}
columns={securityColumns}
getRowId={row => row.id}
virtualized='container'
/>
);
};
Open in Storybook
Window virtualization
virtualized='window' measures against the page's own scroll instead, for a table that is
the page rather than a panel inside one.
import { useMemo, useState } from 'react';
import { Table, type TableColumnSizingState } from '@wallarm-org/design-system/Table';
import { createLargeSecurityEvents, securityColumns } from './mocks';
export const WindowVirtualization = () => {
const largeData = useMemo(() => createLargeSecurityEvents(1000), []);
const [columnOrder, setColumnOrder] = useState<string[]>([]);
const [columnSizing, setColumnSizing] = useState<TableColumnSizingState>({});
return (
<Table
data={largeData}
columns={securityColumns}
getRowId={row => row.id}
virtualized='window'
columnOrder={columnOrder}
onColumnOrderChange={setColumnOrder}
columnSizing={columnSizing}
onColumnSizingChange={setColumnSizing}
/>
);
};
Open in Storybook
The imperative escape hatch: scrollToRow(id, { align }) off the ref, returning false for
an id the table does not have so a deep link can fall back.
import { useMemo, useRef, useState } from 'react';
import { Button } from '@wallarm-org/design-system/Button';
import { HStack, VStack } from '@wallarm-org/design-system/Stack';
import { Table, type TableHandle } from '@wallarm-org/design-system/Table';
import { Text } from '@wallarm-org/design-system/Text';
import { createLargeSecurityEvents, securityColumns } from './mocks';
export const ScrollToRow = () => {
const largeData = useMemo(() => createLargeSecurityEvents(1000), []);
const tableRef = useRef<TableHandle>(null);
const [lastResult, setLastResult] = useState<string | null>(null);
const scroll = (index: number, align: 'start' | 'center' | 'end' | 'auto' = 'center') => {
const row = largeData[index];
if (!row) return;
const ok = tableRef.current?.scrollToRow(row.id, { align, behavior: 'smooth' }) ?? false;
setLastResult(`scrollToRow("${row.id}", { align: "${align}" }) → ${ok}`);
};
return (
<VStack gap={8}>
<HStack gap={8}>
<Button variant='outline' color='neutral' onClick={() => scroll(0, 'start')}>
Top
</Button>
<Button variant='outline' color='neutral' onClick={() => scroll(499, 'center')}>
Middle
</Button>
<Button variant='outline' color='neutral' onClick={() => scroll(999, 'end')}>
Bottom
</Button>
<Button
variant='secondary'
color='neutral'
onClick={() => {
const ok = tableRef.current?.scrollToRow('does-not-exist') ?? false;
setLastResult(`scrollToRow("does-not-exist") → ${ok}`);
}}
>
Missing id (returns false)
</Button>
</HStack>
{lastResult && (
<Text size='sm' color='secondary'>
{lastResult}
</Text>
)}
<Table
ref={tableRef}
className='h-500'
data={largeData}
columns={securityColumns}
getRowId={row => row.id}
virtualized='container'
/>
</VStack>
);
};
Open in Storybook
onEndReached with isLoading — the house default for a long collection, where rows arrive
as you reach them and there is no page number to lose.
import { VStack } from '@wallarm-org/design-system/Stack';
import { Table } from '@wallarm-org/design-system/Table';
import { Text } from '@wallarm-org/design-system/Text';
import { securityColumns, useInfiniteData } from './mocks';
export const InfiniteScroll = () => {
const { data, isFetching, hasMore, totalItems, fetchNextPage } = useInfiniteData();
return (
<VStack gap={8}>
<Text size='sm' color='secondary'>
Loaded {data.length} of {totalItems} rows {isFetching && '— loading...'}
{!hasMore && ' — all loaded'}
</Text>
<Table
className='h-500'
data={data}
columns={securityColumns}
getRowId={row => row.id}
virtualized='container'
isLoading={isFetching}
onEndReached={fetchNextPage}
onEndReachedThreshold={200}
/>
</VStack>
);
};
Open in Storybook
The same fetch-on-approach against the page's scroll, where the trigger has to account for
everything else on the page as well as the table.
import { VStack } from '@wallarm-org/design-system/Stack';
import { Table } from '@wallarm-org/design-system/Table';
import { Text } from '@wallarm-org/design-system/Text';
import { securityColumns, useInfiniteData } from './mocks';
export const InfiniteScrollWindow = () => {
const { data, isFetching, hasMore, totalItems, fetchNextPage } = useInfiniteData();
return (
<VStack gap={8}>
<Text size='sm' color='secondary'>
Loaded {data.length} of {totalItems} rows {isFetching && '— loading...'}
{!hasMore && ' — all loaded'}
</Text>
<Table
data={data}
columns={securityColumns}
getRowId={row => row.id}
virtualized='window'
isLoading={isFetching}
onEndReached={fetchNextPage}
onEndReachedThreshold={300}
/>
</VStack>
);
};
Open in Storybook
Both ends at once: onStartReached prepends, and the scroll position is compensated so the
row you were reading does not slide out from under you.
import { VStack } from '@wallarm-org/design-system/Stack';
import { Table } from '@wallarm-org/design-system/Table';
import { Text } from '@wallarm-org/design-system/Text';
import { securityColumns, useBidirectionalData } from './mocks';
export const BidirectionalInfiniteScroll = () => {
const {
data,
anchorId,
isFetchingPrev,
isFetchingNext,
hasPrev,
hasNext,
fetchPrevPage,
fetchNextPage,
} = useBidirectionalData();
return (
<VStack gap={8}>
<Text size='sm' color='secondary'>
Window of {data.length} rows around the anchor — scroll up for top skeletons, down for
bottom ones{(isFetchingPrev || isFetchingNext) && ' — loading...'}
{!hasPrev && ' — top reached'}
{!hasNext && ' — bottom reached'}
</Text>
<Table
className='h-500'
data={data}
columns={securityColumns}
getRowId={row => row.id}
virtualized='container'
isLoading={isFetchingNext}
isLoadingPrevious={isFetchingPrev}
initialScrollToRowId={anchorId}
onStartReached={fetchPrevPage}
onStartReachedThreshold={200}
onEndReached={fetchNextPage}
onEndReachedThreshold={200}
/>
</VStack>
);
};
Open in Storybook
The prepend case against the document scroll, where unrelated page content shares the height
being measured — the regression this story exists to catch.
import { VStack } from '@wallarm-org/design-system/Stack';
import { Table } from '@wallarm-org/design-system/Table';
import { Text } from '@wallarm-org/design-system/Text';
import { securityColumns, useBidirectionalData } from './mocks';
export const BidirectionalInfiniteScrollWindow = () => {
const {
data,
anchorId,
isFetchingPrev,
isFetchingNext,
hasPrev,
hasNext,
fetchPrevPage,
fetchNextPage,
} = useBidirectionalData();
return (
<VStack gap={8}>
<Text size='sm' color='secondary'>
Window of {data.length} rows around the anchor
{(isFetchingPrev || isFetchingNext) && ' — loading...'}
{!hasPrev && ' — top reached'}
{!hasNext && ' — bottom reached'}
</Text>
<Table
data={data}
columns={securityColumns}
getRowId={row => row.id}
virtualized='window'
isLoading={isFetchingNext}
isLoadingPrevious={isFetchingPrev}
initialScrollToRowId={anchorId}
onStartReached={fetchPrevPage}
onStartReachedThreshold={200}
onEndReached={fetchNextPage}
onEndReachedThreshold={200}
/>
</VStack>
);
};
Open in Storybook
virtualized='window' in an app shell that scrolls a content pane instead of the document: the
table follows the pane. The overflow-x: hidden wrapper in between never scrolls (its
overflow-y only computes to auto), so it must not be taken for the scroll root.
import { VStack } from '@wallarm-org/design-system/Stack';
import { Table } from '@wallarm-org/design-system/Table';
import { Text } from '@wallarm-org/design-system/Text';
import { securityColumns, useBidirectionalData } from './mocks';
export const InfiniteScrollWindowInPane = () => {
const { data, isFetchingPrev, isFetchingNext, hasPrev, hasNext, fetchPrevPage, fetchNextPage } =
useBidirectionalData();
return (
<div data-testid='scroll-pane' style={{ height: 480, overflowY: 'auto' }}>
<div style={{ overflowX: 'hidden' }}>
<VStack gap={8}>
<Text size='sm' color='secondary'>
Window of {data.length} rows around the anchor
{(isFetchingPrev || isFetchingNext) && ' — loading...'}
{!hasPrev && ' — top reached'}
{!hasNext && ' — bottom reached'}
</Text>
<Table
data={data}
columns={securityColumns}
getRowId={row => row.id}
virtualized='window'
isLoading={isFetchingNext}
isLoadingPrevious={isFetchingPrev}
onStartReached={fetchPrevPage}
onStartReachedThreshold={200}
onEndReached={fetchNextPage}
onEndReachedThreshold={200}
/>
</VStack>
</div>
</div>
);
};
Open in Storybook
A share-link landing in a pane host: the window-mode table opens scrolled to its anchor row,
inside the pane — the document stays put.
import { Table } from '@wallarm-org/design-system/Table';
import { securityColumns, useBidirectionalData } from './mocks';
export const InfiniteScrollWindowInPaneAnchored = () => {
const { data, anchorId, isFetchingPrev, isFetchingNext, fetchPrevPage, fetchNextPage } =
useBidirectionalData();
return (
<div data-testid='scroll-pane' style={{ height: 480, overflowY: 'auto' }}>
<Table
data={data}
columns={securityColumns}
getRowId={row => row.id}
virtualized='window'
isLoading={isFetchingNext}
isLoadingPrevious={isFetchingPrev}
initialScrollToRowId={anchorId}
onStartReached={fetchPrevPage}
onStartReachedThreshold={200}
onEndReached={fetchNextPage}
onEndReachedThreshold={200}
/>
</div>
);
};
Open in Storybook
A pane host that mounts the table before its data: rows arrive after a delay. Neither edge may
fire against the window meanwhile — only the edge the user scrolls to.
import { useEffect, useState } from 'react';
import { Table } from '@wallarm-org/design-system/Table';
import { Text } from '@wallarm-org/design-system/Text';
import { securityColumns, useBidirectionalData } from './mocks';
export const InfiniteScrollWindowInPaneLateRows = () => {
const { data, isFetchingPrev, isFetchingNext, fetchPrevPage, fetchNextPage } =
useBidirectionalData();
const [hasLoaded, setHasLoaded] = useState(false);
const [calls, setCalls] = useState({ start: 0, end: 0 });
useEffect(() => {
const timer = setTimeout(() => setHasLoaded(true), 500);
return () => clearTimeout(timer);
}, []);
return (
<div data-testid='scroll-pane' style={{ height: 480, overflowY: 'auto' }}>
<Text size='sm' color='secondary'>
Edge calls: start {calls.start}, end {calls.end}
</Text>
<Table
data={hasLoaded ? data : []}
columns={securityColumns}
getRowId={row => row.id}
virtualized='window'
isLoading={!hasLoaded || isFetchingNext}
isLoadingPrevious={isFetchingPrev}
// Nothing to page from before the first page — as with a real cursor.
onStartReached={
hasLoaded
? () => {
setCalls(prev => ({ ...prev, start: prev.start + 1 }));
fetchPrevPage();
}
: undefined
}
onStartReachedThreshold={200}
onEndReached={
hasLoaded
? () => {
setCalls(prev => ({ ...prev, end: prev.end + 1 }));
fetchNextPage();
}
: undefined
}
onEndReachedThreshold={200}
/>
</div>
);
};
Open in Storybook
A share-link landing in a pane that starts hidden (a closed tab or drawer): the anchor waits
until the table is shown and laid out, then lands inside the pane.
import { useState } from 'react';
import { Button } from '@wallarm-org/design-system/Button';
import { VStack } from '@wallarm-org/design-system/Stack';
import { Table } from '@wallarm-org/design-system/Table';
import { securityColumns, useBidirectionalData } from './mocks';
export const InfiniteScrollWindowInHiddenPaneAnchored = () => {
const [isShown, setIsShown] = useState(false);
const { data, anchorId, isFetchingPrev, isFetchingNext, fetchPrevPage, fetchNextPage } =
useBidirectionalData();
return (
<VStack gap={8}>
<Button data-testid='show-table' onClick={() => setIsShown(true)}>
Show table
</Button>
<div
data-testid='scroll-pane'
style={{ height: 480, overflowY: 'auto', display: isShown ? 'block' : 'none' }}
>
<Table
data={data}
columns={securityColumns}
getRowId={row => row.id}
virtualized='window'
isLoading={isFetchingNext}
isLoadingPrevious={isFetchingPrev}
initialScrollToRowId={anchorId}
onStartReached={fetchPrevPage}
onStartReachedThreshold={200}
onEndReached={fetchNextPage}
onEndReachedThreshold={200}
/>
</div>
</VStack>
);
};
Open in Storybook
A window-mode table mounting into a pane the user already scrolled (content above it): the pane
keeps its position — adopting the root must not reset it.
import { useState } from 'react';
import { Button } from '@wallarm-org/design-system/Button';
import { VStack } from '@wallarm-org/design-system/Stack';
import { Table } from '@wallarm-org/design-system/Table';
import { Text } from '@wallarm-org/design-system/Text';
import { securityColumns, useBidirectionalData } from './mocks';
export const InfiniteScrollWindowInScrolledPane = () => {
const [isShown, setIsShown] = useState(false);
const { data, isFetchingNext, fetchNextPage } = useBidirectionalData();
return (
<VStack gap={8}>
<Button data-testid='show-table' onClick={() => setIsShown(true)}>
Show table
</Button>
<div data-testid='scroll-pane' style={{ height: 480, overflowY: 'auto' }}>
<div style={{ height: 800 }}>
<Text size='sm' color='secondary'>
Content above the table
</Text>
</div>
{isShown && (
<Table
data={data}
columns={securityColumns}
getRowId={row => row.id}
virtualized='window'
isLoading={isFetchingNext}
onEndReached={fetchNextPage}
onEndReachedThreshold={200}
/>
)}
</div>
</VStack>
);
};
Open in Storybook
Header column description
A column can explain itself: description renders as a second line under the title, or as a
tooltip behind a dashed underline when the header has no room for one.
Master cell with actions
The first column is the master cell — pinned, and the place per-row actions appear on hover,
so every row keeps its verbs in one predictable spot.
Master cell with drawer
onMasterCellClick with activeRowId: the master cell opens a Drawer and the row it
belongs to stays marked while the panel is open.
Master cell with actions and drawer
Master cell with both an action menu and a drawer:
clicking the cell body opens the drawer, clicking the action button opens the menu
without triggering the drawer.
Full featured
Grouping, expansion, selection, pinning, virtualization and the action bar in one table —
the honest test of whether the features cooperate rather than merely exist.
import { useMemo, useState } from 'react';
import { Button } from '@wallarm-org/design-system/Button';
import { Copy, Trash2 } from '@wallarm-org/design-system/icons';
import { Table, TableActionBar, type TableColumnPinningState, type TableColumnSizingState, type TableExpandedState, type TableRowSelectionState, type TableSortingState, type TableVisibilityState } from '@wallarm-org/design-system/Table';
import { createLargeGroupedData, fullFeaturedColumns, headerColumnIds, type SecurityHeaderEntry } from './mocks';
export const FullFeatured = () => {
const data = useMemo(() => createLargeGroupedData(12, 50), []);
const [sorting, setSorting] = useState<TableSortingState>([{ id: 'lastEdited', desc: true }]);
const [rowSelection, setRowSelection] = useState<TableRowSelectionState>({});
const [columnSizing, setColumnSizing] = useState<TableColumnSizingState>({});
const [columnPinning, setColumnPinning] = useState<TableColumnPinningState>({
left: ['objectName'],
});
const [columnOrder, setColumnOrder] = useState<string[]>([]);
const [columnVisibility, setColumnVisibility] = useState<TableVisibilityState>({});
const [expanded, setExpanded] = useState<TableExpandedState>({
'group-0': true,
'group-1': true,
});
return (
<Table<SecurityHeaderEntry>
className='h-500'
data={data}
columns={fullFeaturedColumns}
getRowId={row => row.id}
getSubRows={row => row.children}
// Grouping
expanded={expanded}
onExpandedChange={setExpanded}
// Sorting
sorting={sorting}
onSortingChange={setSorting}
// Row selection
rowSelection={rowSelection}
onRowSelectionChange={setRowSelection}
// Column resizing
columnSizing={columnSizing}
onColumnSizingChange={setColumnSizing}
// Column pinning
columnPinning={columnPinning}
onColumnPinningChange={setColumnPinning}
// Column DnD ordering
columnOrder={columnOrder}
onColumnOrderChange={setColumnOrder}
// Column visibility + settings
columnVisibility={columnVisibility}
onColumnVisibilityChange={setColumnVisibility}
defaultColumnVisibility={{}}
defaultColumnOrder={headerColumnIds}
// Virtual scrolling
virtualized='container'
>
<TableActionBar>
<Button variant='ghost' color='neutral-alt' onClick={onDuplicate}>
<Copy /> Duplicate
</Button>
<Button color='brand' onClick={onDelete}>
<Trash2 /> Delete
</Button>
</TableActionBar>
</Table>
);
};
Open in Storybook
Row reordering
EditableTextCell and EditableSelectCell in the plain columns, with the master column
deliberately read-only — editing in place suits a value the user owns, not one the system
reports. Hovering highlights the whole cell, clicking opens it without the content shifting,
and Enter or blur commits while Escape reverts.
Row reordering in groups
With getSubRows grouping, a row moves only among the rows of its own group; group rows stay
put. showEmptyGroups keeps a group whose getSubRows returns [] on screen as a group row.
Grouped with full width rows
groupRowSpan: 'full' draws renderGroupRow across every data column — title on the left,
actions on the right edge; the content owns the collapse toggle. renderFullWidthRow turns a
regular row into a single spanning banner, and getRowCanDrag keeps the banner and the locked
first header in place while the other rows of the group reorder around them.
Grouped with expand column
groupExpandColumn keeps the expand column in grouping mode: the toggle sits in its own column,
first, and every row of a group — drag handle included — sits to the right of it, while
renderGroupRow keeps the title and the actions.
Row reordering with selection
Row reordering coexists with row selection — each feature has its own column and they
don't interfere with each other.
Row reordering virtualized
Row drag-and-drop with container virtualization — the DragOverlay renders the dragged row
outside the virtualizer so it stays visible even when scrolled out of the virtual window.
import { useCallback, useState } from 'react';
import { arrayMove } from '@dnd-kit/sortable';
import { Table, type TableRowReorderEvent } from '@wallarm-org/design-system/Table';
import { createLargeSecurityEvents, securityColumns } from './mocks';
export const RowReorderingVirtualized = () => {
const [data, setData] = useState(() => createLargeSecurityEvents(100));
const handleRowReorder = useCallback((event: TableRowReorderEvent) => {
setData(prev => {
const oldIndex = prev.findIndex(row => row.id === event.activeRowId);
const newIndex = prev.findIndex(row => row.id === event.overRowId);
if (oldIndex === -1 || newIndex === -1) return prev;
return arrayMove(prev, oldIndex, newIndex);
});
}, []);
return (
<Table
data={data}
columns={securityColumns}
getRowId={row => row.id}
onRowReorder={handleRowReorder}
virtualized='container'
estimateRowHeight={() => 40}
/>
);
};
Open in Storybook
Inline cell editing
Sticky group parent story
The group parent row sticks below the header while its children scroll — scroll through
a group to see the parent pin in place, then release when the group boundary passes.
Sticky group parent virtualized
The same sticky-parent behavior under container virtualization with large grouped data —
the overlay stays in the DOM even when the real parent row is unmounted by the virtualizer.
import { useMemo, useState } from 'react';
import { Table, type TableExpandedState, type TableRowSelectionState, type TableSortingState } from '@wallarm-org/design-system/Table';
import { createLargeGroupedData, headerColumns, type SecurityHeaderEntry } from './mocks';
export const StickyGroupParentVirtualized = () => {
const data = useMemo(() => createLargeGroupedData(12, 50), []);
const [expanded, setExpanded] = useState<TableExpandedState>({
'group-0': true,
'group-1': true,
'group-2': true,
});
const [sorting, setSorting] = useState<TableSortingState>([{ id: 'lastEdited', desc: true }]);
const [rowSelection, setRowSelection] = useState<TableRowSelectionState>({});
return (
<Table<SecurityHeaderEntry>
className='h-500'
data={data}
columns={headerColumns}
getRowId={row => row.id}
getSubRows={row => row.children}
expanded={expanded}
onExpandedChange={setExpanded}
sorting={sorting}
onSortingChange={setSorting}
rowSelection={rowSelection}
onRowSelectionChange={setRowSelection}
virtualized='container'
data-testid='sticky-group-parent-virt-table'
/>
);
};
Open in Storybook
Props
BulkBarSummaryClear
BulkBarSummaryCount
BulkBarSummary
BulkBarSummarySelectAll
EditableSelectCell
EditableTextCell
TableColumnMenuHideItem
TableColumnMenuMoveLeftItem
TableColumnMenuMoveRightItem
TableColumnMenuPinItem
TableColumnMenu
TableColumnMenuSortItem
TableSortTrigger