Data Display

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.

How to install Pro components →See the plans →

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>"

New project or installation trouble? Start here →

Playground

Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.

400
Active edited usageDefault props
<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

PropTypeDefaultDescription
columnsSortableColumn<Row>[]—Column definitions (see the sub-type below)
rowsRow[]—Data rows; each needs a stable, unique id (the FLIP animation and selection both key off it)
defaultSortSortState—Initial sort state in uncontrolled mode
sortSortState | 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)
selectablebooleanfalseShow the checkbox column
selectedstring[]—Controlled list of selected ids; omit it for uncontrolled mode
onSelectedChange(ids: string[]) => void—Called when the selection changes
bulkActionsBulkAction[]—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
maxHeightnumber400Max height of the content area (px); beyond it the table scrolls internally under a sticky header
emptyTextstring"沒有符合的資料" (No matching data)Text shown when there are no rows
zebrabooleanfalseTint every other row
compactbooleanfalseCompact mode: smaller type and padding
classNamestring—Passed through to the outer container

SortableColumn

FieldTypeDefaultDescription
keystring—The field key on each row
labelReactNode—Header content
sortablebooleanfalseWhether clicking the header sorts by this column
align"left" | "center" | "right""left"Cell alignment
widthstring—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

FieldTypeDefaultDescription
keystring—Key of the sorted column
dir"asc" | "desc"—Sort direction

BulkAction

FieldTypeDefaultDescription
idstring—Action id, passed to onBulkAction on click
labelstring—Button text
iconReactNode—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 with YYYY-MM-DD or YYYY/M/D, they compare as times; anything else uses localeCompare. Empty values always sort last regardless of direction, and equal rows keep their original order
  • FLIP reordering: each row uses motion's layout with a stable id key to slide into place. layoutId is 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: sort and selected can 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 are type="button" buttons, and each th carries aria-sort (ascending / descending / none)
  • The select-all and per-row checkboxes have an aria-label; expand buttons carry aria-expanded and an expand/collapse label
  • The bulk-action bar is role="toolbar", its clear button has an aria-label, and every button has a focus-visible ring
  • 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