Mischief

94 / Blocks

Data Table

Typed rows with cells you write, column widths you set or the reader drags, sorting that is one property to switch on, and selection kept in keys rather than positions.

Subscriptions
Email
Ada Lovelace
Workshop
24
2 Nov 2026
Barbara Liskov
Sketch
1
30 Aug 2026
Grace Hopper
Studio
8
18 Sept 2026
Katherine Johnson
Workshop
40
11 Dec 2026
Radia Perlman
Studio
12
24 Jan 2027
5 people85
1 of 5 selected

1 of 5 selected. Drag a column edge, or focus one and use the arrow keys.

Installation

Copy the source into your project, or keep it behind a package.

npx shadcn@latest add Tinkerers-Labs/mischief-ui/data-table
import { DataTable } from "mischief-ui/data-table"
Also installs
  • lucide-react

Or paste it in yourself. The source imports the shared cn helper from @/lib/utils, so point that at your own copy.

registry/default/data-table/data-table.tsx
"use client" import * as React from "react"import { ArrowDown, ArrowUp, ChevronsUpDown } from "lucide-react"import { cn } from "@/lib/utils" export type SortDirection = "asc" | "desc" export type DataTableSort = { column: string; direction: SortDirection } export type SortValue = string | number | boolean | Date | null | undefined export type Column<TRow> = {  /** Identifies the column, and names the field read when there is no value. */

Usage

const columns: Column<Person>[] = [
  { key: "name", header: "Name", sort: true },
  { key: "email", header: "Email", cell: (p) => <a href={`mailto:${p.email}`}>{p.email}</a> },
  { key: "seats", header: "Seats", width: "6rem", align: "end", sort: true },
]

export function People({ people }) {
  return (
    <DataTable
      rows={people}
      columns={columns}
      getKey={(person) => person.id}
      label="People"
    />
  )
}

Three levels of effort

A column with no sort property is not sortable, and its heading is plain text rather than a button that looks pressable and does nothing.

sort: true uses the built-in comparator against the column's value, which is the field named by key unless you gave it one. Numbers compare as numbers, dates as dates, and everything else by the reader's locale, so ten does not sort before nine and Ä does not sort after Z.

{ key: "seats", header: "Seats", sort: true }
{ key: "plan", header: "Plan", sort: (a, b) => RANK[a.plan] - RANK[b.plan] }
Anything with an order that is not alphabetical brings its own comparator.

Pressing a heading a third time clears the sort and returns the rows to the order they arrived in, which is often the order that meant something before anyone touched it.

Empty cells sit at the bottom whichever way the column is pointing. A column of blanks at the top is never what was being asked for.

Widths, and dragging them

The table is laid out with fixed columns and a colgroup, so a width is any CSS length you like. Columns without one share whatever is left over in equal parts, which is the behaviour you would reach for a fraction unit to get.

{ key: "plan", header: "Plan", width: "8rem" }
{ key: "name", header: "Name" }  // takes a share of the rest

With resizable on, every boundary between two columns can be dragged. The last column has no handle, because there is nothing to its right to trade width with. The first drag pins every column to the width it already had, so pulling one boundary does not make the others jump about, and a drag writes straight to the colgroup rather than into state, so moving a boundary renders nothing.

Each handle is a separator that can be focused and moved with the arrow keys, because a table whose columns can only be adjusted by dragging cannot be adjusted by everybody. Double-clicking a handle returns that column to the width you declared, and no column can be dragged below its minimum and lost.

Selection is kept in keys

Passing any of selected, defaultSelected, or onSelectionChange turns selection on. What is stored is whatever getKey returns, never a row position, so sorting the table does not silently change what is selected.

Shift-clicking a checkbox extends from the last one touched, which is what people try first. The heading checkbox selects everything and shows the third, in-between state when only some rows are chosen -- a state that has to be set as a property rather than an attribute, which is why it is easy to leave out.

Clicking a row never selects it. Only the checkbox does. That keeps onRowClick free to mean open this without the two gestures fighting, and keeps a link inside a cell working.

Holding a column while the rest scrolls

A wide table scrolls sideways, and the column saying which row you are looking at is the first thing to go. Pinning holds it against the left edge. The checkbox column is held with it whenever anything is pinned, because a column of checkboxes that has scrolled away from its rows is worse than no checkboxes at all.

{ key: "name", header: "Name", pinned: "start", width: "11rem" }

Each held column's distance from the edge is written as a custom property rather than as a class, which is what lets the offsets follow a drag. Widen a held column and the ones after it move with it on the same frame, without anything re-rendering. Resizing and pinning are a pair: it is resizing that makes a table wide enough to need it.

Waiting, and not flashing while you do

loading fills the body with placeholders shaped like the rows they stand in for: one per column, at the same density, so nothing shifts under the reader when the data lands.

They are held back for a tenth of a second first. Most answers arrive faster than that, and a skeleton that appears and vanishes inside two frames reads as a flicker rather than as progress. The table marks itself busy while it waits, and the placeholders carry no text, so there is nothing for a screen reader to read out of them.

Totals

A column with a footer gets one, and the table grows a foot only when at least one column has asked for it. The function is handed the rows in the order they are shown, so a total is the sum of what is in front of you.

{
  key: "seats",
  header: "Seats",
  align: "end",
  sortFirst: "desc",
  footer: (rows) => rows.reduce((total, row) => total + row.seats, 0),
}
sortFirst earns its place on a number: the first press of Seats nearly always means show me the biggest.

What it deliberately does not do

There is no pagination, no filtering, and no toolbar in here. Mischief already has pagination, and an empty row for when a filter matches nothing, and they compose better as themselves than they would absorbed into this.

<DataTable rows={page} columns={columns} getKey={byId} label="Invoices" />
<Pagination page={page} pageCount={pages} onPageChange={setPage} />

There is no virtualisation either. It would change how every row is rendered, and a few hundred rows do not need it. Reach for a windowing library when you genuinely have thousands.

Nor is there a menu for hiding columns, because there does not need to be. Columns are an array you own, so hiding one is filtering that array before you hand it over, and the widths, the sorting and the pinning all follow from that with nothing else to keep in step.

const shown = columns.filter((column) => visible[column.key])

<DataTable rows={rows} columns={shown} getKey={byId} label="Invoices" />

API

rowsTRow[]The data, in whatever order it arrived.
columnsColumn<TRow>[]One entry per column.
getKey(row) => stringIdentity that survives sorting. Selection is kept in these.
getLabel(row) => stringNames a row, for the checkbox that selects it. Worth passing.
labelstringNames the table. Becomes its caption.
sort / defaultSortDataTableSort | nullWhich column, and which way.
onSortChange(sort) => voidCalled with the new sort, or null.
selected / defaultSelectedstring[]The keys that are selected. Passing any selection prop turns it on.
onSelectionChange(keys: string[]) => voidThe keys after a change.
resizablebooleanLets the reader drag the boundary between columns.
onColumnResize(key, width) => voidFor persisting a width you were given.
density"comfortable" | "compact"Row height. Defaults to "comfortable".
stripedbooleanShades alternate rows.
loadingbooleanShows placeholder rows shaped like the real ones.
loadingRowsnumberHow many placeholders. Defaults to 5.
stickyHeaderbooleanHolds the header while the body scrolls.
rowClassName(row, index) => stringClasses for one row.
onRowClick(row, index) => voidA pointer convenience. Never the only way to reach what it does.
emptyReactNodeShown instead of rows when there are none.

Column<TRow>

keystringIdentifies the column, and names the field read when there is no cell or value.
headerReactNodeThe heading.
cell(row, index) => ReactNodeWhat the cell shows. Defaults to the field named by key.
value(row) => SortValueWhat the column is worth when sorted. Defaults to the field named by key.
widthstringAny CSS width. Columns without one share what is left over.
minWidthnumberNarrowest it may be dragged, in pixels. Defaults to 64.
align"start" | "center" | "end"End also sets tabular figures.
sortboolean | ((a, b) => number)true for the built-in comparator, or your own. Absent means not sortable.
resizablebooleanExcludes one column while the rest stay resizable.
maxWidthnumberWidest it may be dragged. Unbounded by default.
pinned"start"Holds the column against the left edge while the rest scrolls past.
wrapbooleanLets the cell run onto a second line instead of being cut short.
footerReactNode | ((rows) => ReactNode)A summary under the column. The function is given the rows in the order shown.
sortFirst"asc" | "desc"Which way the first press sorts. Defaults to "asc".

Accessibility

A real table with a caption, column headers scoped to their columns, and aria-sort on any column that can be sorted, so the current order is announced when a heading is reached. Sort controls are buttons with a touch-sized target. Resize handles are separators in the tab order, driven by the arrow keys. Every checkbox is named with the row it selects rather than being a column of boxes called Select, and the number chosen is kept in a polite live region. onRowClick is pointer only and is documented as never being the only route to what it does. While loading it marks itself busy, and the placeholders carry no text for anything to read out.