---
title: Table
description: A table that is a table: declared columns, right-aligned numbers, sideways scroll in its own frame.
source: https://adamjurek.com/components/table
---

## Home


A table that renders a plain `<table>` from a declared column list: header
text, a renderer per row, and optional right alignment for numbers. Reach for
it whenever data needs to line up in rows and columns - a pricing grid, a
changelog, package files - and sorting or selection can stay on the page.

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

## Why it is built this way

Columns are declared once, each with its own renderer - that's as far toward a
data grid as this goes. Sorting and selection are the page's state; a table
that owns them becomes a component that owns your data flow instead of just
rendering it. The frame carries `data-lenis-prevent`, so a drag that starts
inside a wide table scrolls the table sideways in its own frame instead of the
page moving underneath it.

> [!NOTE] Install commands are not written here
> Anything in `packages/ui/registry.ts` gets its TanStack and shadcn commands
> attached automatically, so there is nothing to keep in sync.


## Install

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

### TanStack

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

### shadcn

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

### pnpm

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

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

## What you get

| | |
| --- | --- |
| Version | 0.1.0 |
| Category | content · Data |
| Files | `table.tsx` |
| Dependencies | None |
| Tags | data, no-deps |

> [!NOTE] No runtime dependencies
> It brings nothing with it beyond the stylesheet.

## 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 { Table } from "@sushindustries/ui";

interface Category {
	name: string;
	count: number;
}

const rows: Category[] = [
	{ name: "Components", count: 61 },
	{ name: "Blocks", count: 12 },
];

export function Example() {
	return (
		<Table
			rowKey={(row) => row.name}
			columns={[
				{ key: "name", header: "Category", render: (row) => row.name },
				{ key: "count", header: "Items", align: "right", render: (row) => row.count },
			]}
			rows={rows}
		/>
	);
}
```

## What you should see

A real `<table>`: an uppercase, monospace header row, one row per item in
`rows`, and the "Items" column's numbers right-aligned with tabular
figures while "Category" stays left-aligned. The whole thing sits in a
bordered frame that scrolls sideways on its own if the columns are wider
than the viewport - the page itself never grows wider.

## If nothing happens

An empty `rows` array leaves the header row standing with no body under it
- that is correct, not broken. If two rows look identical or React warns
about duplicate keys, `rowKey` is not returning a unique string per row;
check for duplicate ids in the source data before assuming the component is
at fault.


## Guides


## Composing it

The table's own frame carries `data-lenis-prevent`, so a drag that starts
inside a wide table scrolls the table sideways rather than being picked up
by `SmoothScroll` on the page behind it. Nothing about placement matters
beyond that - it works the same inside a card, a section, or bare on a
page.

## Variants

`align` is the one per-column variant, and it is set per `TableColumn`
rather than on the table as a whole - each column decides its own
alignment:

```tsx
{ key: "count", header: "Items", align: "right", render: (row) => row.count }
```

```css
.table td[data-align="right"] {
	text-align: right;
	font-variant-numeric: tabular-nums;
}
```

Leave `align` off and a column reads left, which is correct for names,
labels and anything that is not a number.

## When not to use it

Not a data grid - there is no sorting, selection, resizing or
virtualization built in, on purpose; `rows` renders in the order given and
every row renders every time. For a few dozen rows that is nothing; for
hundreds or thousands, pair this with `@tanstack/react-virtual` rather than
handing all of them to `Table` at once, since nothing here windows the DOM
for you.


## API


<!-- generated:api -->

## Props

| Prop | Type | Default | Does |
| --- | --- | --- | --- |
| `columns` | `readonly TableColumn<Row>[]` | - | Header and renderer per column, in display order. `key` has to be unique across them. |
| `rows` | `readonly Row[]` | - | Rendered in the order given - sorting belongs to the page. Empty leaves the headers standing. |
| `rowKey` | `(row: Row) => string` | - | Stable id per row. |
| `caption?` | `string` | - | Announced description of what the table holds. |

<!-- /generated:api -->

## Notes

`caption` is always visually hidden (`sr-only`) - it exists for a screen
reader to announce what the table holds, not as a heading. A visible title
above the table is markup the caller adds outside the component; `caption`
does not double as one. `Row` is a generic inferred from `columns` and
`rows` together, so `render` and `rowKey` get real types on `row` with no
casting - as long as `columns` and `rows` agree on the same shape.


## 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="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.

## In a page

```tsx
import { Table } from "@sushindustries/ui";

interface Package {
	name: string;
	version: string;
	downloads: number;
}

export function PackageList({ packages }: { packages: Package[] }) {
	return (
		<section className="card p-4">
			<h2>Packages</h2>
			<Table
				caption="Every package in this workspace, with its published version"
				rowKey={(row) => row.name}
				columns={[
					{ key: "name", header: "Package", render: (row) => row.name },
					{ key: "version", header: "Version", render: (row) => row.version },
					{
						key: "downloads",
						header: "Downloads",
						align: "right",
						render: (row) => row.downloads.toLocaleString(),
					},
				]}
				rows={packages}
			/>
		</section>
	);
}
```

## What this example is not

`toLocaleString()` here is the caller formatting a number for display -
`Table` renders whatever `render` returns and has no formatting of its own,
for numbers, dates or anything else.
