Table
tableRenders sortable semantic tables with clickable rows, column headers, and empty states.
Usage
Basic usage
Just pass columns + data; the table header can be sorted by clicking, and even-numbered rows are zebra pattern by default.
| user1@hulian.dev | Administrator | |
| user2@hulian.dev | Edit | |
| user3@hulian.dev | Guest | |
| user4@hulian.dev | Administrator | |
| user5@hulian.dev | Edit | |
| user6@hulian.dev | Guest | |
| user7@hulian.dev | Administrator | |
| user8@hulian.dev | Edit |
const columns: ColumnDef<DemoUser, any>[] = [
{ accessorKey: "name", header: "Name" },
{ accessorKey: "email", header: "Email" },
{ accessorKey: "role", header: "Role" },
];
<Table columns={columns} data={users} />Row selection
enableRowSelection Automatically insert the check box column (including header full selection/half selection state).
| user1@hulian.dev | Administrator | ||
| user2@hulian.dev | Edit | ||
| user3@hulian.dev | Guest | ||
| user4@hulian.dev | Administrator | ||
| user5@hulian.dev | Edit | ||
| user6@hulian.dev | Guest | ||
| user7@hulian.dev | Administrator | ||
| user8@hulian.dev | Edit |
<Table columns={columns} data={users} enableRowSelection />Column filter
Add meta.filterable to the column, and a built-in text filter box will appear in the header.
| user1@hulian.dev | Administrator | |
| user2@hulian.dev | Edit | |
| user3@hulian.dev | Guest | |
| user4@hulian.dev | Administrator | |
| user5@hulian.dev | Edit | |
| user6@hulian.dev | Guest | |
| user7@hulian.dev | Administrator | |
| user8@hulian.dev | Edit |
const filterColumns = [
{ ...columns[0], meta: { filterable: true } },
{ ...columns[1], meta: { filterable: true } },
columns[2],
];
<Table columns={filterColumns} data={users} />Column geometry (column width/alignment/overflow omitted)
size / minSize / maxSize of ColumnDef directly implement the real width; meta.align / meta.headerAlign control alignment; meta.ellipsis overflows and truncates and hovers out Tooltip Read the full text. Columns that do not write size are still adaptive according to the content and will not be nailed to the same width.
| WangLi | user1@hulian.dev | Administrator | u0001 |
| LiJay | user2@hulian.dev | Edit | u0002 |
| ZhangJust | user3@hulian.dev | Guest | u0003 |
| LiuStrong | user4@hulian.dev | Administrator | u0004 |
| ChenJuan | user5@hulian.dev | Edit | u0005 |
const columns: ColumnDef<DemoUser, any>[] = [
{ accessorKey: "name", header: "Name", size: 120 },
{ accessorKey: "email", header: "Email", size: 180, meta: { ellipsis: true } },
{ accessorKey: "role", header: "Role", size: 100, meta: { align: "center" } },
{ accessorKey: "id", header: "Number", size: 120, meta: { align: "right", headerAlign: "right" } },
];
<Table columns={columns} data={users} />Column width drag
resizable After turning it on, a drag handle will appear on the right edge of the meter header. Drag to change the width in real time and double-click to reset. Turn on the fixed layout (drag and drop must have a certain column width); the fixed column welt offset will be recalculated in the same frame as the column width.
| WangLi | user1@hulian.dev | Administrator | u0001 |
| LiJay | user2@hulian.dev | Edit | u0002 |
| ZhangJust | user3@hulian.dev | Guest | u0003 |
| LiuStrong | user4@hulian.dev | Administrator | u0004 |
| ChenJuan | user5@hulian.dev | Edit | u0005 |
<Table columns={columns} data={users} resizable />
// Controlled column width (column id → pixel width), persistable to user preferences
<Table
columns={columns}
data={users}
resizable
columnSizing={sizing}
onColumnSizingChange={setSizing}
/>Expandable details
renderExpandedRow Renders full-width detail panels below rows.
| user1@hulian.dev | Administrator | ||
| user2@hulian.dev | Edit | ||
| user3@hulian.dev | Guest | ||
| user4@hulian.dev | Administrator | ||
| user5@hulian.dev | Edit | ||
| user6@hulian.dev | Guest | ||
| user7@hulian.dev | Administrator | ||
| user8@hulian.dev | Edit |
<Table
columns={columns}
data={users}
renderExpandedRow={(row) => (
<div className="text-sm text-muted">
<div>User ID: {row.original.id}</div>
<div>Email: {row.original.email}</div>
</div>
)}
/>Line click
onRowClick makes the entire row clickable (hover highlighted + cursor-pointer + keyboard accessible); buttons/links in the row are bubbled and isolated to prevent accidental touches. For full page jump, use rowHref instead.
Actions | |||
|---|---|---|---|
| user1@hulian.dev | Administrator | ||
| user2@hulian.dev | Edit | ||
| user3@hulian.dev | Guest | ||
| user4@hulian.dev | Administrator |
Click any blank space in the entire line, or click the "Edit" button within the line to try
<Table
columns={columns}
data={users}
onRowClick={(row) => router.push(`/users/${row.id}`)}
/>
// Or declarative whole-row navigation (whole page jump, cmd/ctrl + click to open new tab)
<Table columns={columns} data={users} rowHref={(row) => `/users/${row.id}`} />Row drag and drop sorting
rowDraggable inserts the drag handle column forward; onRowDragEnd returns relative position semantics (activeId / overId / position), which can directly map the backend { move, target, direction } Sorting interface. The components do not change data, the order is under your control.
Name | Email | Role | |
|---|---|---|---|
| user1@hulian.dev | Administrator | ||
| user2@hulian.dev | Edit | ||
| user3@hulian.dev | Guest | ||
| user4@hulian.dev | Administrator | ||
| user5@hulian.dev | Edit |
Try dragging the leftmost handle to change the order.
<Table
columns={columns}
data={rows}
getRowId={(r) => r.id}
enableSorting={false}
rowDraggable
onRowDragEnd={(e) => {
setRows(e.nextData); // Local optimistic update
api.sortable({ // Drop library: relative position semantics
move: e.activeId,
target: e.overId,
direction: e.position === "after" ? "down" : "up",
});
}}
/>
// The entire row can be dragged (buttons/checkboxes within the row have been gesture-isolated)
<Table columns={columns} data={rows} rowDraggable dragHandle="row" ... />Virtual scrolling
Big data tile table open virtual, 200 rows only render viewport window (fixed height container).
<Table
columns={columns}
data={manyUsers}
virtual={{ enabled: true, height: 360, rowHeight: 44 }}
/>When to use
Use Table for structured two-dimensional records with sorting, selection, trees, or virtualization. Column definitions use TanStack ColumnDef directly. Use ProTable for a complete search-toolbar-pagination page, EditableTable for editable cells, or List for a vertical item stream.
Import
import { Table } from "@hulianui/ui"Props
| Name | Type | Default | Description |
|---|---|---|---|
| columns* | ColumnDef<TData, any>[] | — | TanStack column definitions, including accessor, header, cell, and metadata. |
| data* | TData[] | — | Row data. |
| enableSorting | boolean | true | Disables sortable headers, arrows, and aria-sort when false. |
| sorting | SortingState | — | Controlled sorting; omission uses internal state. |
| striped | boolean | true | Applies alternating row backgrounds. |
| bordered | boolean | true | Adds outer border and radius; disable inside ProTable to avoid a double border. |
| density | "default" | "middle" | "compact" | "default" | Cell padding density. |
| getRowId | (row: TData, index: number) => string | By index | Stable row key. |
| rowClassName | (row: TData, index: number) => string | undefined | — | Additional row class merged with stripe and selection classes. |
| layout | "auto" | "fixed" | "auto" | Auto sizes only explicitly constrained columns; fixed emits every TanStack width and sums table width. |
| resizable | boolean | false | Enables header-edge resizing and double-click reset, forcing fixed layout. |
| columnSizing | ColumnSizingState | — | Controlled column widths by column id. |
| onRowClick | (row: TData, index: number) => void | Off | Makes rows pointer- and keyboard-activatable while isolating embedded controls. |
| rowHref | (row: TData, index: number) => string | undefined | Off | Declarative full-page row navigation, with modifier-click opening a new tab. |
| onRowDoubleClick | (row: TData, index: number) => void | Off | Independent double-click action with embedded-control isolation. |
| enableRowSelection | boolean | ((row: Row<TData>) => boolean) | Off | Adds a checkbox column and optional per-row eligibility. |
| rowSelection | RowSelectionState | — | Controlled selection state. |
| getRowCanExpand | (row: Row<TData>) => boolean | — | Limits expandable rows. |
| getSubRows | (row: TData) => TData[] | undefined | — | Enables a tree with indentation by depth. |
| indent | number | 16 | Tree and detail indentation per level in pixels. |
| expanded | ExpandedState | — | Controlled state shared by tree and detail expansion. |
| columnFilters | ColumnFiltersState | — | Controlled column filters. |
| rowDraggable | boolean | false | Enables dnd-kit row sorting; data remains controlled and the result is returned by onRowDragEnd. |
| dragHandle | "row" | "cell" | "cell" | Uses the entire row or a prepended handle cell. |
| getRowCanDrag | (row: TData, index: number) => boolean | All rows | Disables dragging and drop targeting per row; nested rows are always disabled. |
| virtual | VirtualOptions | Off | Optional virtualization: { enabled; rowHeight?=44; height?=480; overscan?=8 }. |
| className | string | — | Root class name. |
Events
| Event | Type | Description |
|---|---|---|
| onSortingChange | OnChangeFn<SortingState> | Sorting change. |
| onRowSelectionChange | OnChangeFn<RowSelectionState> | Selection change. |
| onExpandedChange | OnChangeFn<ExpandedState> | Tree or detail expansion change. |
| onColumnFiltersChange | OnChangeFn<ColumnFiltersState> | Column-filter change. |
| onColumnSizingChange | OnChangeFn<ColumnSizingState> | Column widths, updated continuously during resize. |
| onRowDragEnd | (e: RowDragEndEvent<TData>) => void | Valid changed drop result with ids, indices, position: "before" | "after", rows, and nextData. |
Slots
| Slot | Type | Description |
|---|---|---|
| renderExpandedRow | (row: Row<TData>) => ReactNode | Adds an expander and renders a full-width detail panel. |
| emptyText | ReactNode | Empty-state copy, defaulting to locale.table.empty. |
| renderEmpty | () => ReactNode | Fully custom empty state, taking precedence over emptyText. |
Additional ColumnDef.meta fields:
| meta | Type | Description | Element Plus equivalent |
|---|---|---|---|
| sticky | "left" | "right" | Pins a column and computes its offset. | fixed |
| filterable | boolean | Renders a built-in text filter in the header. | — |
| align | "left" | "center" | "right" | Horizontal cell alignment. | align |
| headerAlign | "left" | "center" | "right" | Header alignment, otherwise following align. | header-align |
| ellipsis | boolean | Truncates overflow and shows the full raw value in a tooltip. | show-overflow-tooltip |
Column geometry uses TanStack ColumnDef.size, minSize, and maxSize directly. Headers and cells receive matching inline width styles without a <colgroup>.
Usage notes
- Width styles are emitted only for explicit
size,minSize, ormaxSize; this avoids TanStack's default size turning every auto-layout column into equal 150 px widths. - Ellipsis needs a definite
size,maxSize, or fixed layout. Its tooltip uses the raw string or number, not arbitrary custom-cell content. - Sticky offsets sum
getSize(), so pinned columns are forced to that width; setsizeexplicitly and ensure content is wide enough to scroll. - Resizing forces fixed layout. If summed widths are narrower than the container,
min-w-fulllets the browser stretch them and no horizontal scroll occurs. - Virtualization requires optional
@tanstack/react-virtualand is best for flat data, not trees, details, or drag-and-drop. - Disable
borderedwhen Table sits inside another bordered card. - Sorting, selection, expansion, and filtering are internal unless the corresponding state and change handler are both supplied.
rowHrefuseswindow.location.assign; useonRowClickwithrouter.pushfor SPA navigation.onRowClicktakes precedence when both exist.- A browser double-click emits two clicks first, so keep
onRowClickreentrant when also usingonRowDoubleClick. - Embedded interactions are isolated by semantic selectors. Give custom clickable elements an appropriate role.
- Dragging never mutates
data; writee.nextDataor a server result back or the row snaps to its old place. - Do not combine drag order with active column sorting; filtered drag also expresses order only among visible rows.
- Without
getRowId, drag ids are array indices and cannot safely identify backend records. - With
dragHandle="row", Space belongs to dnd-kit and Enter activates the row; use the default cell handle to avoid conflict. - Virtual rows outside the viewport are not drop targets and drag does not auto-page.
- Only top-level tree rows are draggable. Use Tree for hierarchical node movement.
- Current built-in Chinese control labels are
"\u62d6\u62fd\u6392\u5e8f"(“Drag to reorder”),"\u5168\u9009"(“Select all”),"\u9009\u62e9\u884c"(“Select row”),"\u6536\u8d77"/"\u5c55\u5f00"(“Collapse” / “Expand”),"\u7b5b\u9009\u2026"(“Filter…”), dynamic"\u7b5b\u9009 <column>"(“Filter <column>”), and"\u8c03\u6574\u5217\u5bbd"(“Resize column”).emptyTextalso inherits the active locale.
Related
Book3D · ProTable · PricingTable · JsonViewer · EditableTable · List
Playground
| user1@hulian.dev | Administrator | |
| user2@hulian.dev | Edit | |
| user3@hulian.dev | Guest | |
| user4@hulian.dev | Administrator | |
| user5@hulian.dev | Edit | |
| user6@hulian.dev | Guest | |
| user7@hulian.dev | Administrator | |
| user8@hulian.dev | Edit |
<Table
columns={columns}
data={users}
enableSorting={true}
striped={true}
/>