For AI agents: the complete documentation index is available at https://wallarm.github.io/design-system/llms.txt, the full documentation bundle is available at https://wallarm.github.io/design-system/llms-full.txt, and this page is available as Markdown at https://wallarm.github.io/design-system/components/table.md.

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.

With pagination

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.

Row selection window scroll

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.

Context menu

Right-click inside a cell: DropdownMenuContextTrigger puts that column's verbs where the pointer already is, so each column can offer its own.

Settings menu

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.

Settings menu deferred

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.

Settings menu grouped

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

Scroll to row

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

Infinite scroll

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

Infinite scroll window

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

Bidirectional infinite scroll

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

Bidirectional infinite scroll window

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

Infinite scroll window in pane

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

Infinite scroll window in pane anchored

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

Infinite scroll window in pane late rows

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

Infinite scroll window in hidden pane anchored

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

Infinite scroll window in scrolled pane

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.

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

PropTypeDefaultDescription
data *T[]Data array for rows
columns *TableColumnDef<T>[]Column definitions
isLoadingboolean | undefinedfalseShow skeleton rows
isLoadingPreviousboolean | undefinedShow skeleton rows above the first row while a previous page is being fetched (bidirectional infinite scroll — the start-edge counterpart of isLoading). Pair with onStartReached.
skeletonCountnumber | undefinedNumber of skeleton rows to display when loading (default: 6)
getRowId((row: T, index: number) => string) | undefinedRow id accessor for stable row identity
aria-labelstring | undefinedAccessible label for the table
sortingTableSortingState | undefined
onSortingChangecomplex
manualSortingboolean | undefinedWhen true, disables TanStack's client-side sort. The table renders rows in the order they appear in data and only fires onSortingChange on header clicks. Use this when sorting is performed server-side and the server returns pre-sorted rows. Default: false (client-side sort via TanStack's getSortedRowModel).
rowSelectionTableRowSelectionState | undefined
onRowSelectionChangecomplex
enableSelectAllRowsboolean | undefinedRenders the master checkbox in the selection column header. Set false when "all rows" is not a selection the consumer can act on — a listing whose scope the consumer cannot express, for instance — leaving per-row checkboxes (shift-click range selection included) as the only way to select. The column itself stays, so the layout does not shift. Default: true.
onSelectAllRows((selected: boolean) => void) | undefinedCalled when the master checkbox is toggled, with its new state, in addition to the onRowSelectionChange the toggle produces. The state change alone cannot identify the gesture: "every loaded row is selected" is also what checking the last row by hand produces. A consumer that treats the master checkbox as meaning more than the loaded rows — everything matching a server-side filter, say — needs to know the difference, which is what this reports.
columnSizingTableColumnSizingState | undefined
onColumnSizingChangecomplex
columnPinningTableColumnPinningState | undefined
onColumnPinningChangecomplex
columnOrderstring[] | undefined
onColumnOrderChangeTableOnChangeFn<string[]> | undefined
groupingTableGroupingState | undefined
onGroupingChangecomplex
renderGroupRow((row: TableRow<T>) => ReactNode) | undefinedGroup row content for getSubRows grouping. Passing it switches the table to sub-row grouping mode: the auto-injected expand column is dropped, so the content is responsible for its own expand/collapse toggle (row.getToggleExpandedHandler()). Rendered only with groupRowSpan: 'full'. With the default 'master' the group row shows its master (first data) column cell, as before.
groupRowSpan"full" | "master" | undefinedHow a group row lays out its content. - 'master' (default): the master column's cell, then empty filler cells — the content is confined to the master column width. - 'full': renderGroupRow(row) in a single cell spanning every data column (system cells — selection, drag handle — keep their own cells). Use for a group header with a title on the left and actions pinned to the right edge of the table. Requires renderGroupRow.
groupExpandColumnboolean | undefinedKeep the expand column in sub-row grouping mode (renderGroupRow), which otherwise drops it and leaves the toggle to the group row's content. The column comes first, ahead of the drag handle, and shows a toggle on group rows only — so the rows of a group, drag handle included, sit inside it.
getSubRows((row: T) => T[] | undefined) | undefinedSub-row accessor for hierarchical/tree data (used with renderGroupRow)
showEmptyGroupsboolean | undefinedRender a top-level row whose getSubRows returns an empty array as an (empty) group row instead of a plain row — for groups that stay visible with nothing in them yet. Return undefined for rows that are not groups.
expandedTableExpandedState | undefined
onExpandedChangecomplex
renderExpandedRow((row: TableRow<T>) => ReactNode) | undefined
columnVisibilityTableVisibilityState | undefined
onColumnVisibilityChangecomplex
defaultColumnVisibilityTableVisibilityState | undefined
defaultColumnOrderstring[] | undefined
columnGroupsTableColumnGroup[] | undefinedLabeled sections for the column-settings menu. When set, the menu renders these groups (flat, non-reorderable — like the FilterInput field menu) instead of the default pinned/unpinned drag-reorder list. See {@link TableColumnGroup}.
onSettingsOpenChange((open: boolean) => void) | undefinedFired when the built-in column-settings menu opens (true) or closes (false). The menu's open state otherwise lives inside the DS and is not observable, so consumers that want to defer expensive work — e.g. refetch once with the final column selection instead of on every toggle — can hook the close edge here.
deferColumnVisibilityboolean | undefinedHold column-visibility toggles in the built-in settings menu until it closes, then apply them as one columnVisibility change. Without it each switch shows or hides its column at once — a column whose data the consumer only fetches on close then sits empty while the menu is open.
virtualizedTableVirtualized | undefinedEnable row virtualization. 'container' virtualizes within the table's own scroll container; 'window' follows the page scroll — the nearest scrollable ancestor, or the browser window.
stretchboolean | undefinedWhether columns stretch proportionally to fill the container width. When false, columns use their defined sizes and the table may be narrower than its container. Default: true.
estimateRowHeight((index: number) => number) | undefined
overscannumber | undefined
onEndReached(() => void) | undefinedCallback fired when the user scrolls near the end (bottom) of the table
onEndReachedThresholdnumber | undefinedDistance from the bottom (in px) to trigger onEndReached (default: 200)
onStartReached(() => void) | undefinedCallback fired when the user scrolls near the start (top) of the table
onStartReachedThresholdnumber | undefinedDistance from the top (in px) to trigger onStartReached (default: 200)
initialScrollToRowIdstring | undefinedRow id to anchor the initial scroll position to. The table scrolls this row into view on mount and arms the edge detectors only after that initial scroll settles. Use for deep-linking into the middle of a dataset.
onRowReorder((event: TableRowReorderEvent) => void) | undefinedCallback fired when a row is dragged to a new position. When provided, a drag-handle column is auto-injected and row DnD is enabled. With getSubRows grouping, group rows stay put and a row moves only among the rows of its own group.
getRowCanDrag((row: TableRow<T>) => boolean) | undefinedPer-row drag control for onRowReorder. A row for which it returns false shows a disabled grip (none on a full-width row), cannot be dragged and is not a drop target — the other rows of its group move around it. Group rows never move regardless. Default: every non-group row can be dragged.
renderFullWidthRow((row: TableRow<T>) => ReactNode) | undefinedRenders a non-group row as a single cell spanning every data column — a banner or notice row among regular rows. Return undefined to render the row normally, with a cell per column. System cells (selection, drag handle) keep their own cells. Pair with getRowCanDrag to keep such a row in place in a reorderable table. Called on every render of the row: return elements, do not call hooks in the callback itself (components it returns may).
onMasterCellClick((rowId: string) => void) | undefinedCallback fired when the master cell is clicked. Receives the row ID.
activeRowIdstring | null | undefinedID of the currently active (highlighted) row, or null. Controls row highlighting via data-preview-active attribute.
data-testidstring | undefined

BulkBarSummaryClear

PropTypeDefaultDescription
titlestring | undefined
defaultCheckedboolean | undefined
defaultValuestring | number | readonly string[] | undefined
autoFocusboolean | undefined
idstring | undefined
tabIndexnumber | undefined
relstring | undefined
onChangeChangeEventHandler<T, Element> | undefined
disabledboolean | undefinedfalse
namestring | undefined
valuestring | number | readonly string[] | undefined
data-testidstring | undefined

BulkBarSummaryCount

PropTypeDefaultDescription
countnumber | undefinedRenders the default "{count} selected" label.
titlestring | undefined
defaultCheckedboolean | undefined
defaultValuestring | number | readonly string[] | undefined
autoFocusboolean | undefined
idstring | undefined
tabIndexnumber | undefined
relstring | undefined
onChangeChangeEventHandler<T, Element> | undefined
color{ primary: string; 'primary-alt': string; secondary: string; 'secondary-alt': string; 'tertiary-alt': string; danger: string; inherit: string; }primary-alt
size{ xs: string; sm: string; md: string; lg: string; xl: string; }sm
weight{ light: string; regular: string; medium: string; bold: string; }medium
data-testidstring | undefined

BulkBarSummary

PropTypeDefaultDescription
titlestring | undefined
defaultCheckedboolean | undefined
defaultValuestring | number | readonly string[] | undefined
autoFocusboolean | undefined
idstring | undefined
tabIndexnumber | undefined
relstring | undefined
onChangeChangeEventHandler<T, Element> | undefined
data-testidstring | undefined

BulkBarSummarySelectAll

PropTypeDefaultDescription
titlestring | undefined
defaultCheckedboolean | undefined
defaultValuestring | number | readonly string[] | undefined
autoFocusboolean | undefined
idstring | undefined
tabIndexnumber | undefined
relstring | undefined
onChangeChangeEventHandler<T, Element> | undefined
disabledboolean | undefinedfalse
namestring | undefined
valuestring | number | readonly string[] | undefined
data-testidstring | undefined

EditableSelectCell

PropTypeDefaultDescription
value *string | nullCurrent committed value; null / '' renders the placeholder.
onCommit *(value: string) => voidCalled with the picked value when the selection changes.
items *SelectDataItem[]Options for the dropdown.
placeholderReactNodeSelectShown when nothing is selected. Defaults to 'Select'.
titlestring | undefined
defaultCheckedboolean | undefined
defaultValuestring | number | readonly string[] | undefined
autoFocusboolean | undefined
idstring | undefined
tabIndexnumber | undefined
relstring | undefined
data-testidstring | undefined

EditableTextCell

PropTypeDefaultDescription
value *stringCurrent committed value.
onCommit *(value: string) => voidCalled with the new value on Enter / blur (only when it actually changed).
titlestring | undefined
defaultCheckedboolean | undefined
defaultValuestring | number | readonly string[] | undefined
autoFocusboolean | undefined
idstring | undefined
tabIndexnumber | undefined
relstring | undefined
onChangeChangeEventHandler<T, Element> | undefined
data-testidstring | undefined

TableColumnMenuHideItem

PropTypeDefaultDescription
insetboolean | undefined
disabledboolean | undefined
onSelect(() => void) | undefined
valuestring | undefinedExplicit value for programmatic highlighting via highlightedValue
titlestring | undefined
defaultCheckedboolean | undefined
defaultValuestring | number | readonly string[] | undefined
autoFocusboolean | undefined
tabIndexnumber | undefined
relstring | undefined
onChangeChangeEventHandler<T, Element> | undefined
variant{ default: string; brand: string; destructive: string; }
data-testidstring | undefined

TableColumnMenuMoveLeftItem

PropTypeDefaultDescription
insetboolean | undefined
disabledboolean | undefined
onSelect(() => void) | undefined
valuestring | undefinedExplicit value for programmatic highlighting via highlightedValue
titlestring | undefined
defaultCheckedboolean | undefined
defaultValuestring | number | readonly string[] | undefined
autoFocusboolean | undefined
tabIndexnumber | undefined
relstring | undefined
onChangeChangeEventHandler<T, Element> | undefined
variant{ default: string; brand: string; destructive: string; }
data-testidstring | undefined

TableColumnMenuMoveRightItem

PropTypeDefaultDescription
insetboolean | undefined
disabledboolean | undefined
onSelect(() => void) | undefined
valuestring | undefinedExplicit value for programmatic highlighting via highlightedValue
titlestring | undefined
defaultCheckedboolean | undefined
defaultValuestring | number | readonly string[] | undefined
autoFocusboolean | undefined
tabIndexnumber | undefined
relstring | undefined
onChangeChangeEventHandler<T, Element> | undefined
variant{ default: string; brand: string; destructive: string; }
data-testidstring | undefined

TableColumnMenuPinItem

PropTypeDefaultDescription
insetboolean | undefined
disabledboolean | undefined
onSelect(() => void) | undefined
valuestring | undefinedExplicit value for programmatic highlighting via highlightedValue
titlestring | undefined
defaultCheckedboolean | undefined
defaultValuestring | number | readonly string[] | undefined
autoFocusboolean | undefined
tabIndexnumber | undefined
relstring | undefined
onChangeChangeEventHandler<T, Element> | undefined
variant{ default: string; brand: string; destructive: string; }
data-testidstring | undefined

TableColumnMenu

PropTypeDefaultDescription
column *complexColumn from the header render context.
titlestring | undefined
defaultCheckedboolean | undefined
defaultValuestring | number | readonly string[] | undefined
autoFocusboolean | undefined
idstring | undefined
tabIndexnumber | undefined
relstring | undefined
onChangeChangeEventHandler<T, Element> | undefined
disabledboolean | undefined
namestring | undefined
valuestring | number | readonly string[] | undefined
data-testidstring | undefined

TableColumnMenuSortItem

PropTypeDefaultDescription
direction *"desc" | "asc"Direction this item commits when selected.
insetboolean | undefined
disabledboolean | undefined
onSelect(() => void) | undefined
valuestring | undefinedExplicit value for programmatic highlighting via highlightedValue
titlestring | undefined
defaultCheckedboolean | undefined
defaultValuestring | number | readonly string[] | undefined
autoFocusboolean | undefined
tabIndexnumber | undefined
relstring | undefined
onChangeChangeEventHandler<T, Element> | undefined
variant{ default: string; brand: string; destructive: string; }
data-testidstring | undefined

TableScrollHandlerLeft

PropTypeDefaultDescription
titlestring | undefined
defaultCheckedboolean | undefined
defaultValuestring | number | readonly string[] | undefined
autoFocusboolean | undefined
idstring | undefined
tabIndexnumber | undefined
relstring | undefined
onChangeChangeEventHandler<T, Element> | undefined
disabledboolean | undefined
namestring | undefined
valuestring | number | readonly string[] | undefined

TableScrollHandlerRight

PropTypeDefaultDescription
titlestring | undefined
defaultCheckedboolean | undefined
defaultValuestring | number | readonly string[] | undefined
autoFocusboolean | undefined
idstring | undefined
tabIndexnumber | undefined
relstring | undefined
onChangeChangeEventHandler<T, Element> | undefined
disabledboolean | undefined
namestring | undefined
valuestring | number | readonly string[] | undefined

TableSettingsMenuItem

PropTypeDefaultDescription
columnId *stringColumn id from the table's column definitions.
labelstring | undefinedSpecifies the localized strings that identifies the accessibility elements and their states
titlestring | undefined
defaultCheckedboolean | undefined
defaultValuestring | number | readonly string[] | undefined
autoFocusboolean | undefined
idstring | undefined
tabIndexnumber | undefined
relstring | undefined
onChangeChangeEventHandler<T, Element> | undefined
namestring | undefinedThe name of the input field in a switch (Useful for form submission).
valuestring | number | undefinedThe value of switch input. Useful for form submission.
asChildboolean | undefinedUse the provided child element as the default rendered element, combining their props and behavior.
data-testidstring | undefined
idsElementIds | undefinedThe ids of the elements in the switch. Useful for composition.
readOnlyboolean | undefinedWhether the switch is read-only
requiredboolean | undefinedIf true, the switch input is marked as required,
htmlForstring | undefined
invalidboolean | undefinedIf true, the switch is marked as invalid.
a11yModeboolean | undefined

TableSettingsMenu

PropTypeDefaultDescription
titlestring | undefined
defaultCheckedboolean | undefined
defaultValuestring | number | readonly string[] | undefined
autoFocusboolean | undefined
idstring | undefined
tabIndexnumber | undefined
relstring | undefined
onChangeChangeEventHandler<T, Element> | undefined
disabledboolean | undefined
namestring | undefined
valuestring | number | readonly string[] | undefined
data-testidstring | undefined

TableSettingsMenuReset

PropTypeDefaultDescription
titlestring | undefined
defaultCheckedboolean | undefined
defaultValuestring | number | readonly string[] | undefined
autoFocusboolean | undefined
idstring | undefined
tabIndexnumber | undefined
relstring | undefined
onChangeChangeEventHandler<T, Element> | undefined
disabledboolean | undefined
namestring | undefined
valuestring | number | readonly string[] | undefined
data-testidstring | undefined

TableSettingsMenuSearch

PropTypeDefaultDescription
titlestring | undefined
patternstring | undefined
defaultCheckedboolean | undefined
defaultValuestring | number | readonly string[] | undefined
autoFocusboolean | undefined
idstring | undefined
tabIndexnumber | undefined
relstring | undefined
disabledboolean | undefined
namestring | undefined
altstring | undefined
heightstring | number | undefined
srcstring | undefined
widthstring | number | undefined
autoCompleteHTMLInputAutoCompleteAttribute | undefined
maxLengthnumber | undefined
minLengthnumber | undefined
placeholderstring | undefinedSearch
readOnlyboolean | undefined
requiredboolean | undefined
checkedboolean | undefined
stepstring | number | undefined
acceptstring | undefined
maxstring | number | undefined
minstring | number | undefined
multipleboolean | undefined
data-testidstring | undefined

TableSortTrigger

PropTypeDefaultDescription
column *complexColumn from the header render context — wires the trigger to TanStack's sort state. Pass header.column directly: tsx header: ({ column }) => ( <TableSortTrigger column={column} data-analytics-id='users.sort.name'> Name </TableSortTrigger> )
titlestring | undefined
defaultCheckedboolean | undefined
defaultValuestring | number | readonly string[] | undefined
autoFocusboolean | undefined
idstring | undefined
tabIndexnumber | undefined
relstring | undefined
onChangeChangeEventHandler<T, Element> | undefined
disabledboolean | undefined
namestring | undefined
valuestring | number | readonly string[] | undefined
data-testidstring | undefined