Data TableNew

A clean table with tabs, search, date range and multi-select filters, row selection, column toggles, sorting, pagination, pinned columns, and loading, empty and error states.

Preview

Setup

Install with the shadcn CLI

$npx shadcn@latest add https://kinetikui.vercel.app/r/data-table.json

One command adds every file, the npm packages and any Kinetik components it builds on. Needs a components.json; the CLI offers to create one if your project has none.

Or install manually

  1. 1

    Run in your terminal:

    $npm install clsx tailwind-merge @radix-ui/react-slot class-variance-authority framer-motion lucide-react
  2. 2

    Copy lib/utils.ts from the Code tab. Skip if you already have cn().

  3. 3

    Copy styles/tokens.css from the Code tab and import it in your global CSS after Tailwind: @import "./styles/tokens.css";

  4. 4

    This component uses Button, Menubar and Sliding Tabs. Copy components/ui/button/, components/ui/menubar/ and components/ui/sliding-tabs/ from the Code tab too, at the same path so the import resolves. Skip any you already have.

  5. 5

    Create a components/ui/data-table/ folder and copy every file from the Code tab into it. Each file does one job, so you can read or change a piece without the rest.

  6. 6

    Import and render:

    Example
    import { DataTable } from "@/components/ui/data-table"
    
    <DataTable data={rows} columns={[{ key: "name", header: "Name", sortable: true }]} />

Usage

Describe the columns and pass your rows. Out of the box the table searches, filters, sorts and pages for you.

import { DataTable, type DataTableColumn } from "@/components/ui/data-table"

type User = { id: number; name: string; role: string; joined: string }

const columns: DataTableColumn<User>[] = [
  { key: "name", header: "Name", sortable: true, sticky: "left" },
  { key: "role", header: "Role", sortable: true },
  { key: "joined", header: "Joined", align: "right" },
]

export function Users({ users }: { users: User[] }) {
  return (
    <DataTable
      data={users}
      columns={columns}
      rowKey={(user) => user.id}
      title="Team"
      searchable
      filters={[{ key: "role", label: "Role", options: ["Admin", "Editor", "Viewer"] }]}
    />
  )
}

Custom cells

cell renders anything. Sorting and search still use the raw value, or value if you need something else, such as a date as a number.

{
  key: "joined",
  header: "Joined",
  sortable: true,
  value: (user) => new Date(user.joined).getTime(),
  cell: (user) => <time dateTime={user.joined}>{formatDate(user.joined)}</time>,
}

Tabs, dates, selection and columns

Every part of the toolbar is opt-in, so turn on only what your page needs.

<DataTable
  data={orders}
  columns={columns}
  rowKey={(order) => order.id}
  tabs={{ key: "status", options: ["Pending", "In transit", "Delivered"] }}
  dateRange={{ key: "shippedAt", label: "Any ship date" }}
  selectable
  onSelectionChange={(ids) => setSelectedIds(ids)}
  columnToggle
  pageSizeOptions={[10, 25, 50]}
/>
  • tabs shows one value of a field at a time, with an "All" tab first.
  • dateRange keeps rows whose field falls in the range. Values can be Dates, timestamps or ISO strings.
  • selectable adds a checkbox to each row and one in the header for the page. Picks survive paging, searching and filtering, so pass rowKey.
  • columnToggle adds a menu to show and hide columns. Pinned columns always stay.

Bulk delete and row actions

While rows are picked, a bar slides in over the header with the count, Delete and Clear. Delete asks first; deleting every row also asks you to type delete. Remove the rows from your data in onDeleteRows.

Give each row a ⋯ menu with RowActions. It takes the same entries as the Menubar, so labels, separators, icons and red destructive items all work, and it opens above the row when there's no room below.

import { ConfirmDialog, DataTable, RowActions } from "@/components/ui/data-table"

<DataTable
  data={services}
  rowKey={(s) => s.id}
  selectable
  rowNoun={["service", "services"]}
  onDeleteRows={(rows) => removeServices(rows.map((s) => s.id))}
  columns={[
    ...columns,
    {
      key: "actions",
      header: <span className="sr-only">Actions</span>,
      sticky: "right",
      cell: (s) => (
        <RowActions
          label={`Actions for ${s.name}`}
          items={[
            { label: "Redeploy", onSelect: () => redeploy(s.id) },
            { type: "separator" },
            { label: "Delete", destructive: true, onSelect: () => setDeleting(s) },
          ]}
        />
      ),
    },
  ]}
/>

ConfirmDialog is exported too, so a single-row Delete can ask the same way.

Server-side data

Set manual when your API does the work. The table shows data as it is and tells you what changed: search waits 300ms after typing stops, and every change returns to page 1.

<DataTable
  manual
  data={page.rows}
  pageCount={page.pageCount}
  page={pageNumber}
  onPageChange={setPageNumber}
  onSearchChange={setQuery}
  onFiltersChange={setFilters}
  onSortChange={setSort}
  onTabChange={setStatus}
  onDateRangeChange={setDates}
  onPageSizeChange={setPageSize}
  loading={isFetching}
  columns={columns}
  searchable
/>

Props

PropTypeDefaultNotes
dataT[]—The rows.
columnsDataTableColumn<T>[]—See below.
rowKey(row, index) => keyIndexA stable key per row.
titleReactNode—Shown above the table, with an optional description.
searchablebooleanfalseShows a search box. Narrow it with searchKeys.
filters{ key; label; options }[]—Multi-select filters. Choosing none shows everything.
tabs{ key; options; allLabel? }—Tabs that show one value of a field, plus All. With defaultTab and onTabChange.
dateRange{ key; label? }—A date range picker with presets. With defaultDateRange and onDateRangeChange.
selectablebooleanfalseRow checkboxes, and select-all for the page. Read picks with onSelectionChange.
onDeleteRows(rows) => void—Adds Delete to the selection bar, behind a confirmation.
bulkActions(rows, clear) => ReactNode—More buttons for the selection bar.
rowNoun[string, string]["row", "rows"]What a row is called in the confirmation.
columnTogglebooleanfalseA menu to show and hide columns. Start some hidden with defaultHiddenColumns.
sort{ key; direction } | null—Controlled sort. Or defaultSort to start sorted.
scrollablebooleantrueKeep columns full width and scroll sideways. Off, the table fits its box and text wraps.
pageSizenumber10Rows per page.
pageSizeOptionsnumber[]—Shows a rows-per-page picker, e.g. [10, 25, 50]. With onPageSizeChange.
pagenumber—Controlled page, with onPageChange.
manualbooleanfalseData is already searched, filtered, sorted and paged. Pass pageCount.
loadingbooleanfalseShows skeleton rows.
errorReactNode—Shown in place of the rows.
emptyMessageReactNode"No results"Shown when there are no rows.

Columns

FieldTypeNotes
keystringField on the row, and the column's id.
headerReactNodeColumn title. Add muted text beside it with subtitle, e.g. a unit.
align"left" | "center" | "right"Right-align numbers.
sortablebooleanClick cycles ascending, descending, unsorted.
sticky"left" | "right"Keeps the column in view while the table scrolls sideways.
cell(row) => ReactNodeCustom cell.
value(row) => unknownWhat sorting and search use, when it differs from the field.
minWidthnumber | stringMinimum column width.

Accessibility

  • A real table with th scope="col"; sorted columns announce aria-sort
  • Sort controls are buttons, so headers sort from the keyboard
  • Filters, the columns menu and row selection use real checkboxes; Escape or a click outside closes menus
  • Tabs come from Sliding Tabs, so arrow keys move between them
  • Selected rows set aria-selected, and the page checkbox reports a mixed state when some rows are picked
  • The selection bar is a labelled toolbar and announces the count as it changes
  • The confirmation is a modal alertdialog: focus stays inside, starts on Cancel, and Escape cancels
  • Row menus are real menus: arrow keys, Home, End and Escape work, and focus returns to the ⋯ button
  • Pagination is a labelled nav, and the current page has aria-current
  • Loading rows set aria-busy on the table body