npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@welldone-product/data-table

v1.3.1

Published

Admin data table for React: selection, drag-and-drop reorder, accordion rows, infinite-scroll rows, pagination. Ships precompiled CSS — no Tailwind required.

Readme

@welldone-product/data-table

npm license: MIT

Admin data table for React: row selection, column sorting, drag-and-drop reorder, accordion rows, infinite-scroll rows, locked-row tooltips, pagination, and a select field — with precompiled CSS. No Tailwind setup required.

Every feature is exercised by the interactive scenario gallery in smoke/ (the same app that backs the real-browser regression suite) — run it locally with cd smoke && npm run dev.

Install

yarn add @welldone-product/data-table
# or: npm install @welldone-product/data-table

Peer dependencies: react and react-dom 18 or newer.

Quick start

import '@welldone-product/data-table/styles.css';

import {
  DataTable,
  Pagination,
  type TableColumn,
} from '@welldone-product/data-table';

interface User {
  id: string;
  name: string;
  role: string;
}

const columns: TableColumn<User>[] = [
  { key: 'name', header: 'Name' },
  { key: 'role', header: 'Role', render: user => <em>{user.role}</em> },
];

function UserList({ users, total, page, setPage }: Props) {
  return (
    <>
      <DataTable columns={columns} data={users} rowKey={u => u.id} />
      <Pagination
        page={page}
        pageSize={20}
        total={total}
        onPageChange={setPage}
      />
    </>
  );
}

Import the stylesheet once (app entry). Data fetching, filtering, and URL state are yours — the components render what they are given.

Components

DataTable<T>

| Prop | Type | Description | | ------------------------------------------------------------------------------------ | ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | columns | DataTableColumnDef<T>[] | Leaf columns (key, header, optional align/width/render(row, index)/sortable/resizable/minWidth/maxWidth/pinned/cellClassName) and/or grouped headers | | data | T[] | Row objects; T is unconstrained | | rowKey | (row, index) => string | Stable row identity (defaults to index) | | onClickRow | (row) => void | Row click handler | | loading / loadingText | boolean / string | Loading row | | emptyText / emptyImageSrc | string / string | Simple empty state | | emptyState | ReactNode | Replaces the whole empty state | | selectable / selectedKeys / onSelectionChange | | Controlled checkbox selection | | isRowSelectable | (row, index) => boolean | Locks rows out of selection | | lockedRowTooltip | ReactNode \| (row) => ReactNode | Reason shown on locked rows — tooltip on the data cells; a string reason is also a native title on the disabled checkbox | | selectAllLabel / selectRowLabel | string | Checkbox aria-labels | | isDndEnabled / canDragRow / onReorder | | HTML5 drag-and-drop row reorder. Rows where canDragRow returns false are hoisted above the draggable rows and can be neither dragged nor used as a drop target; onReorder receives the full row array in display order, locked rows first. Don't leave it to client-side sorting — a re-sort discards the dragged order; see Sorting when row order carries meaning and Row reorder with locked rows | | columnOrder / defaultColumnOrder / onColumnOrderChange / enableColumnReorder | | Header drag-and-drop column reorder. Drop feedback in v1 is opacity-only on the dragged header (no insertion line indicator) | | sorting / defaultSorting / onSortingChange | SortState \| null | Click-to-sort headers on columns with sortable: true. Uncontrolled sorts client-side; passing onSortingChange hands sorting off to the caller (server-side) | | columnWidths / defaultColumnWidths / onColumnWidthsChange | Record<string, number> | Pointer-drag column resize on columns with resizable: true (minWidth/maxWidth clamp, default min 60px). onColumnWidthsChange fires once per drag, on pointer up | | columnVisibility / defaultColumnVisibility / onColumnVisibilityChange | Record<string, boolean> | Controlled column show/hide; a key absent from the map stays visible. Pair with DataTableColumnToggle, or drive the same state yourself | | columns[].pinned | 'left' \| 'right' | Sticky column locked while scrolling horizontally. Requires an explicit width (width or a columnWidths entry) — an unpinned width logs a dev warning and columns can overlap. Pinned cells render an opaque background, including hover/selected row tinting, so content never shows through while scrolling. When any column is left-pinned, the selection checkbox and drag-handle columns auto-pin left too; see Pinned columns and a saved layout | | accordion | DataTableAccordionConfig<T> | Expand/collapse per row (columnKey, isExpandable, isExpanded, onToggle, onAccordionOpen, expandLabel, collapseLabel). Child rows are yours to splice into data, so pair it with onSortingChange — see Sorting when row order carries meaning; for late-arriving children see Lazy accordion children | | rowClassName | (row, index) => string \| undefined | Per-row classes; disables the default zebra stripe | | isLoaderRow / isSentinelRow / tableRowRef | | Infinite-scroll integration hooks — see Infinite scroll with a sentinel row | | unstyled / className / tableWrapperClassName | | Wrapper styling control. The header is always sticky (position: sticky, no prop to disable it) — give tableWrapperClassName a max-height to scope vertical scrolling to the table | | virtual | DataTableVirtualConfig | Opt-in row virtualization for large datasets ({ maxHeight: number \| string }, number is px). Renders only the rows near the viewport instead of the full dataset — see Virtualization | | appendRow | { label?, onAppend } | "Add row" affordance rendered as the last table row (hidden while loading). Activating it only calls onAppend — append to data yourself; see Adding & editing rows | | footer | DataTableFooterConfig | Summary row in <tfoot> — cells aligns values under their columns, content is a free-form bar. You compute every value; see Footer summary |

Grouped headers (merged header cells)

Nest a group node — header + columns, no key — anywhere in the columns array to render Excel-style merged header cells. Spans are always derived: ungrouped columns merge vertically (rowSpan), group cells span their visible leaves (colSpan). You never write span numbers.

const columns: DataTableColumnDef<Po>[] = [
  { key: 'category', header: 'Category' }, // merges vertically
  {
    header: 'Shipped Info.',
    columns: [
      { key: 'country', header: 'Country', sortable: true },
      { key: 'via', header: 'Via' },
      // nest another group here for 3-level headers
    ],
  },
];
  • Groups are presentational only — data, sorting, widths, resize and pinning all live on leaf columns.
  • Hiding leaves (via columnVisibility) shrinks the group span; hiding all of them removes the group cell; DataTableColumnToggle shows group headers as section labels.
  • Pinning is atomic per group: give every leaf in a group the same pinned value. Mixed values log a dev warning and unpin the whole group.
  • Reordering is atomic per group: dragging a group header moves the whole group; leaves can only be reordered within their own group. A persisted columnOrder that interleaves group leaves is re-clustered automatically.

Keyboard interactions

Every pointer interaction has a keyboard path:

| Action | Keys | | -------------- | ------------------------------------------------------------------------------------------ | | Sort | Tab to the header button, Enter/Space | | Resize | Tab to the boundary handle, Arrow keys ±10px, Shift+Arrow ±50px | | Row reorder | Tab to the row grip, Space/Enter to grab or drop, Arrow keys to move, Escape to cancel | | Column reorder | Tab to the header (when enableColumnReorder), Alt+ArrowLeft/Right — groups move as units | | Row activate | Tab to the row (when onClickRow), Enter/Space | | Scroll | Tab to the table wrapper (focusable only when it overflows), Arrow keys |

Reorder moves are announced to screen readers via a polite live region — localize the strings with the reorderMessages prop (koLabels.dataTable ships a Korean bundle).

Footer summary (recipe)

The table lays the footer out; you decide what it summarizes — the current page, the selected rows, or a total the server computed. Nothing is aggregated for you, so server-paginated tables can show a true grand total.

// Column-aligned: values sit under their columns and follow width, order,
// visibility, pinning and resize automatically.
const shown = selectedKeys.length
  ? rows.filter(r => selectedKeys.includes(r.id))
  : pageRows;

<DataTable
  columns={columns}
  data={pageRows}
  rowKey={r => r.id}
  footer={{
    cells: {
      name: selectedKeys.length ? `Selected ${shown.length}` : 'Total',
      amount: fmt(shown.reduce((s, r) => s + r.amount, 0)),
    },
  }}
/>;

// Free-form: one bar across the full width — labels, values, even a button.
<DataTable
  columns={columns}
  data={pageRows}
  rowKey={r => r.id}
  tableWrapperClassName="my-fixed-height"
  footer={{ content: <SummaryBar qty={qty} amount={amount} /> }}
/>;

Notes:

  • The footer sticks to the bottom edge by default (sticky: false to opt out).
  • A sticky footer also stretches the table to its container so the summary stays on the bottom edge when the rows do not reach it — the leftover area stays blank rather than being padded with placeholder rows. Give the wrapper a height with your own CSS (tableWrapperClassName) — the bundled stylesheet only contains the classes the library itself uses, so arbitrary Tailwind classes from your app will not apply unless your app compiles them.

Adding & editing rows (recipe)

The table is fully controlled — it never mutates data. That makes spreadsheet-style flows a few lines of app code:

const [rows, setRows] = useState<User[]>(initial);

// 1. Adding: the appendRow affordance calls you back; you own the append.
<DataTable
  columns={columns}
  data={rows}
  rowKey={r => r.id}
  appendRow={{
    label: '+ Add user',
    onAppend: () => setRows(r => [...r, { id: nanoid(), name: '', role: '' }]),
  }}
/>;

// 2. Editing: a cell `render` can return any React node — including inputs.
const columns: TableColumn<User>[] = [
  {
    key: 'name',
    header: 'Name',
    render: user => (
      <input
        value={user.name}
        onChange={e =>
          setRows(rs =>
            rs.map(r =>
              r.id === user.id ? { ...r, name: e.target.value } : r,
            ),
          )
        }
      />
    ),
  },
];

Caveats for editable cells:

  • Set rowKey — index-based identity breaks input focus when rows are added, removed, or sorted.
  • Don't combine editing with uncontrolled client-side sorting — every keystroke re-sorts under the cursor. Use controlled sorting and apply it on commit (e.g. blur), or hand sorting to the server via onSortingChange.
  • A full editing engine (edit modes, cell navigation, validation, clipboard) is out of scope for this library — reach for a spreadsheet grid if you need that.

Caveats for appendRow — the affordance always renders as the last table row, but where your new row lands is up to the pipeline your data flows through:

  • Active sort teleports the new row. With uncontrolled sorting the table re-sorts on every data change, so a blank row sorts by its empty values — typically to the top, nowhere near the button. Either clear/control sorting when appending, or give the draft row values that sort next to where the user is looking.
  • Pagination: the new row is usually on another page. If you slice data per page, appending to the full array adds the row to the last page while the user stays on the current one. Insert into the current slice, or jump to the row's page after appending.
  • Infinite scroll competes for the same spot. A bottom sentinel loads more rows on reaching the end — the same gesture needed to reach the append button, which then keeps moving down as pages arrive. Prefer a toolbar "Add" action over appendRow for infinite lists.
  • Row drag-and-drop resets its uncommitted local order when the row set changes — commit the order via onReorder state before appending.

Row reorder with locked rows (recipe)

When the row order is the data — a picking route, a task queue, a playlist — drag-and-drop becomes the editor, and some rows are usually finished and must stay put:

// One predicate, reused for isRowSelectable below.
const canDrag = (row: Task) => row.status !== 'done';

const [rows, setRows] = useState<Task[]>(initial);
const [selected, setSelected] = useState<string[]>([]);

<DataTable
  columns={columns}
  data={rows}
  rowKey={r => r.id}
  isDndEnabled
  canDragRow={canDrag}
  onReorder={setRows}
  selectable
  selectedKeys={selected}
  onSelectionChange={setSelected}
  isRowSelectable={canDrag}
  lockedRowTooltip="Finished rows can no longer be reordered or edited"
/>;

Notes:

  • Reuse one predicate when "locked" also means "not actionable": the same function for canDragRow and isRowSelectable keeps the grip and the checkbox telling one story, and lockedRowTooltip names the reason. Locked rows are hoisted above the draggable ones, their relative order preserved.
  • onReorder hands back the full display order — commit it to state, and persist on an explicit save action if the order is a work instruction.
  • Leave sorting off — an uncontrolled sort overwrites the dragged order; to have both, take the sort over with onSortingChange — see Sorting when row order carries meaning.
  • Every drag has a keyboard path — see Keyboard interactions.

Lazy accordion children (recipe)

Child rows and the expansion state both live in your component — children are ordinary rows you splice into data under their parent. That means children arriving late from the server are just more state, plus one placeholder row while they travel:

const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [childrenById, setChildrenById] = useState<Record<string, Row[]>>({});
const requested = useRef(new Set<string>());

const loadChildren = useCallback(async (parent: Row) => {
  if (requested.current.has(parent.id)) return; // re-opens are free
  requested.current.add(parent.id);
  const children = await fetchChildren(parent.id);
  setChildrenById(prev => ({ ...prev, [parent.id]: children }));
}, []);

const data = useMemo(
  () =>
    parents.flatMap(parent => {
      if (!expanded.has(parent.id)) return [parent];
      const children = childrenById[parent.id];
      if (children) return [parent, ...children];
      // Not here yet — a placeholder holds the slot until the memo re-runs.
      return [parent, { ...PENDING_ROW, id: `${parent.id}/__pending` }];
    }),
  [parents, expanded, childrenById],
);

<DataTable
  columns={columns}
  data={data}
  rowKey={r => r.id}
  // Inline is fine — the table takes `accordion` apart into primitives, so
  // rows stay memoized without any useMemo ceremony on your side.
  accordion={{
    columnKey: 'name',
    isExpandable: row => row.kind === 'parent',
    isExpanded: row => expanded.has(row.id),
    onToggle: row =>
      setExpanded(prev => {
        const next = new Set(prev);
        if (!next.delete(row.id)) next.add(row.id);
        return next;
      }),
    onAccordionOpen: loadChildren, // fires on open only, never on collapse
  }}
/>;

Notes:

  • The placeholder is a real row — key it under its parent and guard anything row-facing against it (cell renders, isRowSelectable, selection bookkeeping).
  • onAccordionOpen only fires from the toggle. An "expand all" button that sets your expansion state directly must call the loader itself.
  • Sorting scatters spliced-in children — see Sorting when row order carries meaning for taking the sort over.

Infinite scroll with a sentinel row (recipe)

There is no fetch-more callback. You splice a sentinel row into the tail of data and observe it — which keeps every policy decision (when to stop, what pauses the tail) in your code:

const SENTINEL_ID = '__sentinel';
const LOADER_ID = '__loader';
// Cells never render for sentinel/loader rows — stubs satisfying Row suffice.

const data = useMemo(() => {
  const tail =
    fetching && loaded.length ? [LOADER_ROW] : hasMore ? [SENTINEL_ROW] : [];
  return [...loaded, ...tail];
}, [loaded, fetching, hasMore]);

// The observer callback outlives the render that attached it — read the
// latest loader through a ref so a late fire can't call a stale closure.
const loadNextRef = useRef(loadNext);
useEffect(() => {
  loadNextRef.current = loadNext;
});

const observer = useRef<IntersectionObserver | null>(null);
useEffect(() => () => observer.current?.disconnect(), []);

// No useCallback needed — the table pins `tableRowRef` internally.
const rowRef = (
  { rowKey }: { rowKey: string },
  el: HTMLTableRowElement | null,
) => {
  if (rowKey !== SENTINEL_ID) return;
  observer.current?.disconnect();
  if (!el) return;
  observer.current = new IntersectionObserver(
    entries => {
      if (entries.some(e => e.isIntersecting)) loadNextRef.current();
    },
    // The root must be the table's own scroll container — with the default
    // root (the viewport), a table low on the page never fires. The library
    // renders that container as the table's direct wrapper:
    { root: el.closest('table')?.parentElement },
  );
  observer.current.observe(el);
};

<DataTable
  columns={columns}
  data={data}
  rowKey={r => r.id}
  isSentinelRow={r => r.id === SENTINEL_ID}
  isLoaderRow={r => r.id === LOADER_ID}
  tableRowRef={rowRef}
  loading={!loaded.length && fetching}
  loadingText="Loading…"
  virtual={{ maxHeight: 480 }}
/>;

Notes:

  • Attach the observer in the ref callback, not an effect — under virtual the sentinel mounts from the virtualizer's own re-renders, which your effects never observe (the tableRowRef JSDoc carries the details).
  • The first page loads itself: before any rows arrive, data is just the sentinel, which is immediately visible — the observer fires and fetches page one. loading/loadingText covers that first round trip; the loader row takes over for later pages.
  • Pause the tail while filtering client-side — reaching the end of a filtered list says nothing about the server's end, so append no sentinel while a filter is active.
  • Composes with virtual — see Virtualization.

Pinned columns and a saved layout (recipe)

The wide-ledger combo — 20+ columns, identifiers pinned left, row actions pinned right, and the user's column widths and visibility surviving a refresh:

// Module scope (or useMemo) — `columns` is the one prop that must keep its
// identity: an inline array literal re-renders every row on every render.
const columns: TableColumn<Item>[] = [
  { key: 'sku', header: 'SKU', width: '140px', pinned: 'left' },
  // …many data columns, one of them widthless to absorb leftover space…
  { key: 'actions', header: '', width: '64px', pinned: 'right', render: rowActions },
];

interface Layout {
  widths: Record<string, number>;
  visibility: Record<string, boolean>;
}
const KEY = 'stock-ledger/layout';
const EMPTY_LAYOUT: Layout = { widths: {}, visibility: {} };

const [layout, setLayout] = useState<Layout>(
  () => JSON.parse(localStorage.getItem(KEY) ?? 'null') ?? EMPTY_LAYOUT,
);
const save = (next: Layout) => {
  setLayout(next);
  localStorage.setItem(KEY, JSON.stringify(next));
};

<DataTableColumnToggle
  columns={columns}
  columnVisibility={layout.visibility}
  onColumnVisibilityChange={visibility => save({ ...layout, visibility })}
/>
<DataTable
  columns={columns}
  data={rows}
  rowKey={r => r.sku}
  columnWidths={layout.widths}
  onColumnWidthsChange={widths => save({ ...layout, widths })}
  columnVisibility={layout.visibility}
  virtual={{ maxHeight: 520 }}
/>

Notes:

  • Pinned columns need an explicit width — pin offsets are computed from the widths of the columns before them (see columns[].pinned in the props table).
  • Visibility changes originate from the toggle, so the handler lives on DataTableColumnToggle; DataTable itself never calls onColumnVisibilityChange.
  • Writing straight to localStorage in onColumnWidthsChange is fine — it fires once per drag, not per pointer-move.
  • Keep position utilities out of cellClassName on pinned columns — they override the pin's sticky and quietly unpin the column (see the cellClassName JSDoc). Put cell editors in unpinned columns.
  • columnOrder persists the same way — [] is a valid saved value meaning the original order, so the layout can stay controlled from the first render.

DataTableColumnToggle<T>

Dropdown checklist for showing/hiding DataTable columns. Share the same columnVisibility state between the two to keep them in sync:

<DataTableColumnToggle
  columns={columns}
  columnVisibility={visibility}
  onColumnVisibilityChange={setVisibility}
/>

Props: columns (same array passed to DataTable; header is used as the checkbox label), columnVisibility / defaultColumnVisibility / onColumnVisibilityChange, label (trigger button text, default 'Columns'), className.

Sorting, resize, visibility & reorder together

const columns: TableColumn<User>[] = [
  { key: 'name', header: 'Name', sortable: true },
  { key: 'role', header: 'Role', resizable: true, minWidth: 100 },
];

const [visibility, setVisibility] = useState<Record<string, boolean>>({});
const [widths, setWidths] = useState<Record<string, number>>({});

<DataTableColumnToggle
  columns={columns}
  columnVisibility={visibility}
  onColumnVisibilityChange={setVisibility}
/>
<DataTable
  columns={columns} // sortable/resizable/pinned live on the column definition
  data={rows}
  rowKey={r => r.id}
  defaultSorting={{ key: 'name', direction: 'asc' }}
  columnVisibility={visibility}
  onColumnVisibilityChange={setVisibility}
  columnWidths={widths}
  onColumnWidthsChange={setWidths}
  enableColumnReorder
/>

Pagination

1-based page, pageSize, total, onPageChange; optional onPageSizeChange (renders a page-size select only when provided), pageSizeOptions, and labels (prev, next, total(n), pageSize(n), nav). Rendering several paginations on one page? Give each a distinct labels.nav — duplicate identical <nav> landmarks fail WCAG.

SelectField

Single-select dropdown used by Pagination and exported for standalone use: options, value, onSelect, plus label/placeholder/status props and noDataText / noDataImageSrc / noDataState for the empty dropdown.

Font

Typography helper (variant: h1–h4, p-*, mono; as element override).

Localization

Text defaults are English. A Korean preset ships with the package:

import { DataTable, Pagination, koLabels } from '@welldone-product/data-table';

<DataTable {...koLabels.dataTable} … />
<Pagination labels={koLabels.pagination} … />

koLabels covers pagination, dataTable, dataTableAccordion, selectField, and dataTableColumnToggle (label) prop bundles. Any other language: pass your own strings to the same props.

Column widths

The table is pinned to the sum of its columns only once every column has a width. Until then the widthless column absorbs whatever space is left over, which is how the table fills a container wider than its columns need. Give every column a width and that leftover becomes dead space on the right instead.

So pick the column that deserves the room — usually a name or a description, rarely a number or a badge — and leave its width off:

const columns: TableColumn<Order>[] = [
  { key: 'code', header: 'Order', width: '150px' },
  { key: 'vendor', header: 'Vendor' }, // ← takes the leftover width
  { key: 'amount', header: 'Amount', width: '130px', align: 'right' },
];

Two things follow from it, both worth knowing before you rely on it:

  • It is the first column to be squeezed. When the container is narrower than the other columns need, the leftover is negative and comes out of that column, down to nothing. If your table can get that narrow, give it a floor and let the wrapper scroll below it:

    .my-table table {
      min-width: 900px;
    } /* sum of fixed widths + a usable minimum */
  • Resizing ends it. Dragging any resize handle freezes the widthless column at its measured width, so from then on every column has one and the table is pinned to their sum. This is deliberate: a width you dragged is honoured exactly rather than being re-stretched on the next container change. Treat the fill as a starting layout, not a running one.

Sorting when row order carries meaning

Client-side sorting treats rows as interchangeable and reorders data as one flat list. Two features put meaning in that order and lose it to a sort: accordion, whose child rows are ordinary rows you splice in under their parent, and isDndEnabled, whose order is whatever the user dragged. Nothing tells the table which row belongs where, so a sort scatters the children and discards the drag.

Take the sort over with onSortingChange and order data yourself — for an accordion, sort the parents and keep each one immediately followed by its children. The table keeps drawing the sort indicator and stops touching the rows. Left to sort either feature on its own, it warns once in the console.

const [sorting, setSorting] = useState<SortState | null>(null);

const rows = useMemo(() => {
  const by =
    sorting &&
    ((a: Row, b: Row) =>
      (a.score - b.score) * (sorting.direction === 'asc' ? 1 : -1));
  const parents = by ? [...groups].sort(by) : groups;
  return parents.flatMap(parent => {
    if (!expanded.has(parent.id)) return [parent];
    const children = [...parent.children];
    if (by) children.sort(by);
    return [parent, ...children];
  });
}, [groups, expanded, sorting]);

<DataTable
  data={rows}
  sorting={sorting}
  onSortingChange={setSorting}
  accordion={{ columnKey: 'name', isExpandable, isExpanded, onToggle }}
/>;

Virtualization

Opt in for large row sets with virtual — only the rows near the viewport are rendered as <tr> elements instead of the whole dataset:

<DataTable
  columns={[
    { key: 'name', header: 'Name', width: '200px' },
    { key: 'value', header: 'Value', width: '120px' },
  ]}
  data={tenThousandRows}
  rowKey={r => r.id}
  virtual={{ maxHeight: 480 }}
/>
  • maxHeight is required (number in px, or a CSS length string). It sets the scroll viewport height on the table wrapper — without an explicit height there's no viewport to virtualize against.
  • Don't combine with tableWrapperClassName's max-h-*. virtual.maxHeight is applied as an inline style on the same wrapper element, so it always wins over a class; a max-h-* class there becomes dead weight. Set the height only through virtual.maxHeight.
  • Don't change the wrapper's overflow via tableWrapperClassName (e.g. overflow-visible) — virtualization measures scroll on that same element, so a non-scrolling wrapper stops it dead.
  • Set rowKey. Strongly recommended — the default index-based key breaks the virtualizer's row-height measurement cache across sorts, inserts, or removals.
  • Set column width. A virtualized table forces table-fixed layout, so columns without an explicit width fall back to an equal split of the remaining space instead of sizing to content.
  • Row drag-and-drop (isDndEnabled) is viewport-limited. HTML5 drag-and-drop can only drop onto rows that exist in the DOM, so long-distance drags outside the rendered window aren't supported. Reordering within the visible rows works normally.
  • Infinite scroll composes as expected. Sentinel/loader rows (isSentinelRow/isLoaderRow) still trigger their IntersectionObserver once they scroll into the rendered window — but attach the observer in tableRowRef, not an effect; see Infinite scroll with a sentinel row.
  • Pagination vs. virtualization: Pagination is the default choice for typical admin lists. Reach for virtual only when the UI genuinely needs a single scrollable view over a large row set (logs, master-data browsing) — the two aren't meant to be combined.

Styling & customization

Four levers, weakest first. Reach for the next one only when the previous can't do it.

1. Theme tokens (--wdp-*)

All colors resolve through --wdp-* CSS variables (HSL triplets) with defaults bundled in styles.css. Defaults follow the WDS design system (slate neutrals, slate-900 primary, red-600 destructive) — see DESIGN.md for the full token catalog and rationale. Override any of them after the stylesheet import:

:root {
  --wdp-primary: 262 83% 58%; /* tooltip/checkbox accent */
  --wdp-border: 214 32% 91%; /* borders & row dividers */
  --wdp-radius: 4px; /* base corner radius */
}

Available variables: --wdp-background, --wdp-foreground(-alt), --wdp-surface, --wdp-primary(-foreground), --wdp-secondary(-foreground), --wdp-destructive(-foreground), --wdp-muted(-foreground), --wdp-accent(-foreground), --wdp-popover(-foreground), --wdp-border, --wdp-input, --wdp-ring, --wdp-success, --wdp-warning, --wdp-info, --wdp-radius, --wdp-font-mono.

The stylesheet's reset rules are scoped to the components (.wdp) and never touch your page. The components are designed for a light theme.

2. className props

  • className — extra classes on the table root.
  • tableWrapperClassName — the scroll wrapper around the table.
  • rowClassName={(row, index) => …} — per-row conditional classes (setting it disables the default zebra stripe). When you reach into the cells from such a class, note that isDndEnabled and selectable each prepend a <td> of their own — the drag handle first, then the checkbox. So td:first-child is one of those, not your first column, and the offset is one or two cells depending on which props are on. Prefer targeting the row (.my-row td { … }) or a column's own cellClassName, which follows the column wherever it moves.
  • Column width / align on each TableColumn control sizing and alignment per column. See Column widths for what happens to the space left over.

3. Descendant CSS

For anything without a token or prop (header weight/height, border width, cell padding …), target the table from your own stylesheet — the bundled reset is scoped to .wdp and easy to override:

.my-table th {
  font-weight: 700;
  height: 56px;
}
<DataTable className="my-table" … />

4. unstyled

Removes the outer wrapper skin (rounded corners, surface background, shadow) so the table drops flat into your own container styling.

Security

Cell render functions must return React nodes — never inject HTML strings (dangerouslySetInnerHTML, ref-based innerHTML). React's escaping is the XSS boundary.

License

MIT © WellDoneProduct