Sortable Data Table
General-purpose data table with FLIP row reordering on header sort, a sticky header, expandable row details, checkbox selection with a bulk-action bar, and an empty state
This is a WebberUI Pro component
The install command below needs a licence to run; without one it returns 401.
The table every admin panel needs: clicking a header cycles through ascending, descending, and unsorted, and each row FLIP-slides from its old position to its new one so you can see what moved where; content taller than maxHeight scrolls inside the table under a sticky header. Checking any row springs a bulk-action bar in from the top (a "已選 N 筆" (N selected) count, action buttons, and a clear button); pass expandable and each row gets a leading chevron that opens a detail row with a height animation. Columns take custom renderers and comparators, and the default comparator auto-detects numbers, numeric strings, and date strings.
Bring this into your project
Run this command in your project folder. The CLI adds the source and dependencies; then follow the usage example below.
npx webberui@latest add "https://webberui.com/r/sortable-data-table.en.json?t=<install token>"Playground
Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.
<SortableDataTable/>
The complete example includes the preview layout and data. Install the component first, then paste this file into your project.
Installation
npx webberui@latest add "https://webberui.com/r/sortable-data-table.en.json?t=<install token>"Or, once registries are configured in components.json, install it as @webberui/sortable-data-table.
Usage
import {
SortableDataTable,
type SortableColumn,
} from "@/components/ui/sortable-data-table";
type Order = {
id: string;
customer: string;
amount: number;
status: string;
date: string;
};
const columns: SortableColumn<Order>[] = [
{ key: "id", label: "訂單編號", sortable: true },
{ key: "customer", label: "客戶", sortable: true },
{
key: "amount",
label: "金額",
sortable: true,
align: "right",
render: (row) => `NT$ ${row.amount.toLocaleString("zh-TW")}`,
},
{ key: "status", label: "狀態" },
{ key: "date", label: "日期", sortable: true },
];
<SortableDataTable
columns={columns}
rows={orders}
defaultSort={{ key: "date", dir: "desc" }}
selectable
bulkActions={[{ id: "ship", label: "標記出貨" }]}
onBulkAction={(actionId, ids) => markShipped(ids)}
expandable={(row) => <OrderDetail order={row} />}
/>;Props
| Prop | Type | Default | Description |
|---|---|---|---|
columns | SortableColumn<Row>[] | — | Column definitions (see the sub-type below) |
rows | Row[] | — | Data rows; each needs a stable, unique id (the FLIP animation and selection both key off it) |
defaultSort | SortState | — | Initial sort state in uncontrolled mode |
sort | SortState | null | — | Controlled sort state (null means unsorted); omit it for uncontrolled mode |
onSortChange | (sort: SortState | null) => void | — | Called when the sort changes (header clicks cycle asc → desc → unsorted) |
selectable | boolean | false | Show the checkbox column |
selected | string[] | — | Controlled list of selected ids; omit it for uncontrolled mode |
onSelectedChange | (ids: string[]) => void | — | Called when the selection changes |
bulkActions | BulkAction[] | — | Bulk actions; the action bar slides in from the top once anything is selected |
onBulkAction | (actionId: string, ids: string[]) => void | — | Called when a bulk-action button is clicked |
expandable | (row: Row) => ReactNode | — | Row detail renderer; when provided, each row gets a leading expand chevron |
maxHeight | number | 400 | Max height of the content area (px); beyond it the table scrolls internally under a sticky header |
emptyText | string | "沒有符合的資料" (No matching data) | Text shown when there are no rows |
zebra | boolean | false | Tint every other row |
compact | boolean | false | Compact mode: smaller type and padding |
className | string | — | Passed through to the outer container |
SortableColumn
| Field | Type | Default | Description |
|---|---|---|---|
key | string | — | The field key on each row |
label | ReactNode | — | Header content |
sortable | boolean | false | Whether clicking the header sorts by this column |
align | "left" | "center" | "right" | "left" | Cell alignment |
width | string | — | Column width (any valid CSS width, e.g. "120px") |
render | (row: Row) => ReactNode | — | Custom cell renderer; without it the raw value is shown (empty values show —) |
sortFn | (a: Row, b: Row) => number | — | Custom comparator, always written in ascending terms — the component flips it for descending; auto-detected when omitted |
SortState
| Field | Type | Default | Description |
|---|---|---|---|
key | string | — | Key of the sorted column |
dir | "asc" | "desc" | — | Sort direction |
BulkAction
| Field | Type | Default | Description |
|---|---|---|---|
id | string | — | Action id, passed to onBulkAction on click |
label | string | — | Button text |
icon | ReactNode | — | Icon shown left of the label |
The SortableRowBase, SortableColumn, SortState, and BulkAction types are also exported by name, along with the default comparator compareCellValues(a, b) for reuse inside a custom sortFn.
How it works
- Sort cycle: clicking the same header steps through ascending → descending → unsorted; clicking a different column starts at ascending. Unsorted returns to the original order of
rows - Default comparison: when both sides are numbers or numeric strings, they compare as numbers (so
"12"does not sort before"3"); when both are date strings starting withYYYY-MM-DDorYYYY/M/D, they compare as times; anything else useslocaleCompare. Empty values always sort last regardless of direction, and equal rows keep their original order - FLIP reordering: each row uses
motion'slayoutwith a stableidkey to slide into place.layoutIdis deliberately avoided — it is a global namespace, so two tables on the same page sharing a row id would trigger a shared-element animation by mistake - Controlled and uncontrolled:
sortandselectedcan each be controlled or uncontrolled; expansion state is managed internally - The header select-all checkbox shows an indeterminate state when only some rows are checked; select-all only applies to rows currently in
rows
Accessibility
- Uses semantic
table/thead/th scope="col"; sortable headers aretype="button"buttons, and eachthcarriesaria-sort(ascending/descending/none) - The select-all and per-row checkboxes have an
aria-label; expand buttons carryaria-expandedand an expand/collapse label - The bulk-action bar is
role="toolbar", its clear button has anaria-label, and every button has afocus-visiblering - When the user's system has "reduce motion" enabled, rows reorder instantly without the FLIP slide, the bulk-action bar and detail rows appear without animation, and the header arrow flips instantly
Weather Widget
An animated weather card with sun-glow, raindrop, drifting-cloud, and snowflake scenes, day/night colors, and a week forecast
Status Badge
A badge with a count that rolls digit by digit, a removable mode that shrinks out while neighbours slide into place, and a pulsing variant for live states.