@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.
Maintainers
Readme
@welldone-product/data-table
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-tablePeer 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;DataTableColumnToggleshows group headers as section labels. - Pinning is atomic per group: give every leaf in a group the same
pinnedvalue. 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
columnOrderthat 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: falseto 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
sortingand apply it on commit (e.g. blur), or hand sorting to the server viaonSortingChange. - 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
datachange, so a blank row sorts by its empty values — typically to the top, nowhere near the button. Either clear/controlsortingwhen 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
dataper 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
appendRowfor infinite lists. - Row drag-and-drop resets its uncommitted local order when the row set
changes — commit the order via
onReorderstate 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
canDragRowandisRowSelectablekeeps the grip and the checkbox telling one story, andlockedRowTooltipnames the reason. Locked rows are hoisted above the draggable ones, their relative order preserved. onReorderhands 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). onAccordionOpenonly 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
virtualthe sentinel mounts from the virtualizer's own re-renders, which your effects never observe (thetableRowRefJSDoc carries the details). - The first page loads itself: before any rows arrive,
datais just the sentinel, which is immediately visible — the observer fires and fetches page one.loading/loadingTextcovers 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 (seecolumns[].pinnedin the props table). - Visibility changes originate from the toggle, so the handler lives on
DataTableColumnToggle;DataTableitself never callsonColumnVisibilityChange. - Writing straight to
localStorageinonColumnWidthsChangeis fine — it fires once per drag, not per pointer-move. - Keep
positionutilities out ofcellClassNameon pinned columns — they override the pin'sstickyand quietly unpin the column (see thecellClassNameJSDoc). Put cell editors in unpinned columns. columnOrderpersists 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 }}
/>maxHeightis required (numberin 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'smax-h-*.virtual.maxHeightis applied as an inline style on the same wrapper element, so it always wins over a class; amax-h-*class there becomes dead weight. Set the height only throughvirtual.maxHeight. - Don't change the wrapper's
overflowviatableWrapperClassName(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 forcestable-fixedlayout, so columns without an explicitwidthfall 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 theirIntersectionObserveronce they scroll into the rendered window — but attach the observer intableRowRef, not an effect; see Infinite scroll with a sentinel row. - Pagination vs. virtualization:
Paginationis the default choice for typical admin lists. Reach forvirtualonly 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 thatisDndEnabledandselectableeach prepend a<td>of their own — the drag handle first, then the checkbox. Sotd:first-childis 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 owncellClassName, which follows the column wherever it moves.- Column
width/alignon eachTableColumncontrol 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
