---
title: Data Table
description: The same table with TanStack Table under it: sorting, filtering and paging that somebody else has already tested.
source: https://adamjurek.com/components/data-table
---

## Home


One paragraph on what this does and when to reach for it. This is the first
thing on the component's page and the line that goes into `llms.txt`, so it
should survive being read on its own.

<!-- ::start:showcase demo="data-table" height="380" -->
<!-- ::end:showcase -->

## Why it is built this way

The decision somebody would otherwise have to reverse-engineer from the source.
Not what the code does - the source says that - but what it is avoiding.

## What it does not do

The boundary. A component that lists what it is not is a component you can
decide against in ten seconds.

> [!NOTE] Install commands are not written here
> Anything in `packages/ui/registry.ts` gets its TanStack, shadcn and pnpm
> commands appended to the bottom of this tab, along with its version,
> dependencies and files. Do not add your own - the generated ones cannot go
> stale, and a second copy immediately does.


## Install

<!-- ::start:tabs -->

### TanStack

```shell
tanstack add https://adamjurek.com/r/tanstack/data-table.json
```

### shadcn

```shell
pnpm dlx shadcn@latest add https://adamjurek.com/r/shadcn/data-table.json
```

### pnpm

```shell
pnpm add @sushindustries/ui @sushindustries/atoms
```

<!-- ::end:tabs -->

## What you get

| | |
| --- | --- |
| Version | 0.1.0 |
| Category | content · Data |
| Files | `data-table.tsx` |
| Dependencies | `@tanstack/react-table@^9.1.2` |
| Tags | data, sorting, filter, pagination, tanstack |


## Get Started


Install commands are on Home, attached from the registry - they are not written
here, because a second copy is a copy that goes stale. This tab starts after the
install worked.

## Use it

```tsx
import { DataTable } from "@sushindustries/ui";
import "@sushindustries/atoms/atoms.css";

const rows = [
	{ kind: "source", files: 96, tokens: 412_310 },
	{ kind: "component", files: 245, tokens: 188_004 },
];

export function Example() {
	return (
		<DataTable
			label="Documents by kind, with their token cost"
			rows={rows}
			sortBy="tokens"
			descending
			columns={[
				{ id: "kind", header: "Kind", sortable: true },
				{ id: "tokens", header: "Tokens", numeric: true, sortable: true },
			]}
		/>
	);
}
```

## What you should see

A bordered table with a sticky header row, `Tokens` right-aligned in tabular
figures, and a down arrow beside it because `sortBy` and `descending` set the
first sort. The two sortable headers are buttons: clicking one toggles it, and
the arrow slot holds its width so the header row never shifts sideways.
`label` is the caption, read out and never drawn.

## If nothing happens

Headers that are plain text with no border and no banding mean
`@sushindustries/atoms/atoms.css` was never imported - the frame and every cell
rule live there. A header that will not sort has no `sortable: true` on its
column; that is off by default, because a column of long prose sorts into
nonsense. An empty table is not an error, it says `empty` - usually a filter.


## Guides


## Composing it

A column is an id into the row plus how that column is meant to be read.
`numeric` right-aligns it, `mono` makes it breakable for a path or a hash, and
`cell` takes over the rendering when the raw value is not what a reader wants.

```tsx
const columns = [
	{ id: "path", header: "Path", mono: true },
	{ id: "kind", header: "Kind", sortable: true },
	{
		id: "tokens",
		header: "Tokens",
		numeric: true,
		sortable: true,
		cell: (row) => row.tokens.toLocaleString(),
	},
];
```

The frame is `overflow-x-auto max-w-full border rounded-xl bg-1` in the markup,
not a block class. That is what guarantees a table's border and radius match
the cards beside it rather than drifting from them.

## Density and banding

Both are attributes on the table, so a caller cannot half-apply one by passing
a stray class name.

| Prop | Value | For |
| --- | --- | --- |
| `density` | `comfortable` (default) | A table somebody reads a few rows of |
| `density` | `compact` | Half the padding, same type size. For scanning fifty rows, where the padding is most of the height |
| `striped` | `true` | Bands alternate rows, so the eye cannot slip between the first column and the last. Earns its place on a wide table |

Type size is the same in both densities. Shrinking the text to fit more of it
is where a dense table stops being readable and becomes a screenshot of a
table. And banding on a three-column table has no distance to slip across, so
there it is decoration that makes every second row look selected.

## Sorting is somebody else's tested code

TanStack Table v9 does the row models and the comparators; this adds the markup
and the class names, which is the half a headless library deliberately has no
opinion about. v9 is not v8 - it takes an explicit `features` object and
`create*RowModel`, so v8's shape type-errors here rather than rendering an
empty table.

## When not to use it

`Table` is the same look with no dependency, and it is the right answer for a
table that is read once and sorted never. Filtering, pagination and grouping
are not here either: each is one more feature slot when something needs it, and
until then they are code nobody ships.


## API


<!-- generated:api -->

## Props

| Prop | Type | Default | Does |
| --- | --- | --- | --- |
| `rows` | `readonly TRow[]` | - |  |
| `columns` | `readonly DataTableColumn<TRow>[]` | - |  |
| `sortBy?` | `string` | - | Which column to sort by first, and which way. |
| `descending?` | `boolean` | - |  |
| `empty?` | `string` | - | What to say when there are no rows. Not an error - usually a filter. |
| `label` | `string` | - | Announced to screen readers, and never drawn. |
| `density?` | `"comfortable" \| "compact"` | - | How much room each row gets. `comfortable` is a table somebody reads a few rows of. `compact` is one they scan fifty rows of, which is a genuinely different job: at fifty rows the padding is most of the height, and a table that needs two screens to show what fits on one is a table people stop scrolling. Only the padding and the line height change. The type stays the same size in both, because shrinking text to fit more of it is where a dense table stops being readable and starts being a screenshot. |
| `striped?` | `boolean` | - | Shades alternate rows. Off by default and worth turning on for wide tables specifically: banding exists to stop the eye slipping a row between the first column and the last, and a three-column table has no such distance to slip across. On a narrow table it is decoration that makes every second row look selected. |

<!-- /generated:api -->

## Notes

Anything the types cannot say: which combinations are meaningless, which
prop is ignored when another is set, and what it does when handed
something it cannot render.


## Examples


Examples are the tab where the component is shown doing a job, not
demonstrating a prop. The API tab already lists the props.

<!-- ::start:showcase demo="data-table" height="420" -->
<!-- ::end:showcase -->

Press Compare. The frames are real viewports, so a layout that breaks at 320
breaks here too rather than in somebody's hands.

## Fifty rows somebody is scanning

Six columns of build output: paths in mono, sizes in figures, a menu in the
last column. This is the shape both variants were written for - `compact`
because fifty rows of comfortable padding need two screens, `striped` because
at six columns wide the eye slips a row between the first and the last.

```tsx
import { DataTable, DropdownMenu } from "@sushindustries/ui";

export function Artefacts({ artefacts, onAction }) {
	return (
		<DataTable
			label="Build artefacts, largest first"
			rows={artefacts}
			sortBy="bytes"
			descending
			density="compact"
			striped
			empty="No artefacts from this build."
			columns={[
				{ id: "path", header: "Path", mono: true },
				{ id: "kind", header: "Kind", sortable: true },
				{
					id: "bytes",
					header: "Size",
					numeric: true,
					sortable: true,
					cell: (row) => `${(row.bytes / 1024).toFixed(1)} kB`,
				},
				{
					id: "id",
					header: "",
					cell: (row) => (
						<DropdownMenu
							label="Actions"
							align="end"
							items={[{ id: "open", label: "Open", icon: "link" }]}
							onSelect={(action) => onAction(action, row)}
						/>
					),
				},
			]}
		/>
	);
}
```

## What this example is not

Nothing here filters. `empty` is what shows when `rows` arrives empty, and
`rows` is the caller's - a filtered list is a filtered array passed in, not a
prop on this component. The row menu is a `DropdownMenu` in a `cell`, and it
aligns to `end` because it is the last column; that is a choice about this
table, not something the column knows to do.
