# tablekit
> Accessible, token-driven React data table for design systems and AI coding agents. Prefer schema mode: ` ` from `@tablekit/react`, where the whole table is one strict JSON object (columns with types text/number/currency/date/badge/avatar/link/boolean/actions). Validate with `parseTableSchema` from `@tablekit/core/schema`. Colors come only from semantic badge tones and `--tk-*` CSS tokens; presets exist for shadcn/ui, Material 3, Carbon, Radix.
Install: `npm i @tablekit/react` and `import "@tablekit/react/styles.css"`, or `npx shadcn@latest add https://tablekit.amitpatjoshi.com/r/tablekit.json`.
## Docs
- [Introduction](https://tablekit.amitpatjoshi.com/getting-started/introduction/): What tablekit is, how it's organized, and which API to use.
- [Install](https://tablekit.amitpatjoshi.com/getting-started/install/): Install tablekit with npm, the shadcn CLI, or an AI agent.
- [Schema mode](https://tablekit.amitpatjoshi.com/guides/schema/): The TableSchema reference, the declarative config behind .
- [Composable API](https://tablekit.amitpatjoshi.com/guides/composable/): Build custom tables from useTable and the Table.* parts.
- [Theming & design systems](https://tablekit.amitpatjoshi.com/guides/theming/): Tokens, dark mode, presets for shadcn/ui, Material 3, Carbon and Radix, and Figma via DTCG.
- [Responsive & mobile](https://tablekit.amitpatjoshi.com/guides/responsive/): How tablekit adapts to narrow containers with stacked cards, scrolling, or priority columns.
- [Accessibility](https://tablekit.amitpatjoshi.com/guides/accessibility/): What tablekit does for keyboard, screen reader and low-vision users, and what you still need to do.
- [Server-side data](https://tablekit.amitpatjoshi.com/guides/server-data/): Sort, filter and paginate on the server with controlled state.
- [Agent guide](https://tablekit.amitpatjoshi.com/agents/overview/): How AI coding agents find, install and correctly use tablekit, and how to make your agent better at it.
## Machine-readable
- [llms-full.txt](https://tablekit.amitpatjoshi.com/llms-full.txt): all docs, agent rules, recipes and the JSON Schema in one file
- [TableSchema JSON Schema](https://tablekit.amitpatjoshi.com/schema/v1.json): validate or constrain generated configs
- [shadcn registry item](https://tablekit.amitpatjoshi.com/r/tablekit.json): source install
- [Registry index](https://tablekit.amitpatjoshi.com/r/registry.json)
## Optional
- [Prompt recipes](https://tablekit.amitpatjoshi.com/agents/recipes/): 11 copy-paste prompts with expected schemas
- [Playground](https://tablekit.amitpatjoshi.com/playground/): live schema editor
---
# Agent rules (AGENTS.md)
You are using **tablekit**, an accessible, token-driven React table. Follow these rules.
Full docs for agents: https://tablekit.amitpatjoshi.com/llms-full.txt
## Default: schema mode
```tsx
import { DataTable } from "@tablekit/react";
import "@tablekit/react/styles.css"; // once, in the root layout
{}}
onBulkAction={(actionId, rows) => {}}
/>
```
## Rules
1. **Validate every schema** you write: `parseTableSchema(schema)` from `@tablekit/core/schema`. The schema is strict, so unknown keys are errors. Fix every error it reports.
2. Column `type` is one of: `text | number | currency | date | badge | avatar | link | boolean | actions | button`. Type-specific keys (`badge`, `avatar`, `link`, `actions`) are only valid on their own type.
3. **Colors:** use badge `tones` only: `neutral | info | success | warning | danger | accent`. Never put hex, rgb or Tailwind color classes on the table.
4. **Styling:** override `--tk-*` CSS variables, or import a preset (`@tablekit/tokens/presets/shadcn.css`, `material3.css`, `carbon.css`, `radix.css`). Don't add utility classes to table internals.
5. **Font:** the table is designed for Inter (`npm i @fontsource-variable/inter`, then `import "@fontsource-variable/inter"`). Numbers use Inter's `"tnum"`, `"zero"` and `"ss01"` via `--tk-font-feature-numeric`. Don't override `font-variant-numeric` in cells.
6. **Data stays raw:** numbers as numbers, dates as ISO strings or `Date`, enums as strings. `type` and `format` handle display.
7. **Server-side data:** `manual`, `rowCount`, `state` and `onStateChange`. Don't paginate on the client when the API paginates.
8. **Mobile:** set `priority` (1 = most important … 5) on secondary columns. `appearance.responsive` is `stack` (cards, the default), `priority` (drop columns) or `scroll`.
9. **Badge styles:** `badge.fill` (default true) and `badge.stroke` (default false). Outline badges = `fill: false, stroke: true`; tinted + bordered = `stroke: true`. Prefer `indicator: "icon"` with `icons` for statuses users scan (payments, deploys).
10. **Logos** (banks, merchants, payment providers): an avatar column with `avatar: { imageField, logo: "inline" }` (16px mark centred on the name's first line, no container) or `logo: "circle"` (mark in a neutral/accent circle). Use the brand's compact symbol mark, not its wordmark. Fills follow the theme (dark in dark mode), so give navy/black marks a light variant via `avatar.imageDarkField`.
11. **Actions:** several row actions → `type: "actions"` (⋯ menu, or `actionsDisplay: "inline"` icons). Never a row of text buttons. One clear call to action per row → a `type: "button"` column.
12. **Column layout:** the Columns menu lets users show/hide (`features.columnVisibility`), pin (`features.columnPinning`) and reorder (`features.columnReorder`) columns. All three are on by default. `column.pinned: true` sets the initial pin; pin the identifying column (name, ID), not numbers. To remember a user's layout, persist `state.columnOrder`, `state.columnPinning` and `state.columnVisibility` from `onStateChange`.
13. **Icons:** the table uses Lucide (`lucide-react`). Match it in custom cells rather than mixing icon sets.
14. Need custom cell markup? Switch to composable mode: `useTable` with `ReactColumnDef.cell` and `` / `` / `` / ``.
15. Don't wrap `DataTable` in your own ``, and don't re-implement sorting, filtering or pagination around it.
## Props cheat sheet
`schema`, `data`, `loading`, `error`, `onRetry`, `onAction`, `onBulkAction`, `onRowClick`, `onSelectionChange`, `state`, `onStateChange`, `manual`, `rowCount`, `density`, `theme` (`"light" | "dark"`), `maxHeight`, `toolbarExtra`, `labels` (i18n), `className`, `style`.
---
# Introduction
> What tablekit is, how it's organized, and which API to use.
tablekit is an open-source table component. It has three goals:
1. **A well-designed, accessible web table** that also works on mobile.
2. **Easy for AI coding agents to use correctly.** Agents fill in a declarative JSON config rather than guessing props.
3. **Works with the design system you already have**, through CSS-variable tokens.
## Packages
| Package | What it is | Depends on |
| --- | --- | --- |
| `@tablekit/core` | Headless logic: sort, filter, paginate, select, columns, formatters. Framework-agnostic TypeScript. | nothing (`/schema` entry uses zod) |
| `@tablekit/react` | Styled React components: `` (schema mode) and `Table.*` parts (composable mode). | core, tokens |
| `@tablekit/tokens` | `tokens.css` (CSS variables), `tokens.json` (W3C DTCG), and design-system presets. | nothing |
## Two ways to use it
**Schema mode.** Best for agents, prototypes, and admin screens. The whole table is one object:
```tsx
```
**Composable mode.** Best for product surfaces that need custom cells or layout. The same features come as parts you arrange yourself:
```tsx
const table = useTable({ data, columns });
```
Both modes share the same core, CSS and tokens. You can start in schema mode and move to composable mode later without redesigning anything.
## What's in v1
Sorting (multi-sort with `Shift`), global search, column filters (text / select / range) with removable chips, pagination, single and multi row selection with a bulk-action bar, row action menus, column visibility, column resize (pointer and keyboard), pinned first column, sticky header, three densities, zebra and bordered variants, loading / empty / no-results / error states, responsive card stacking, and priority-based column hiding.
Planned for later: virtualization, inline editing, grouping, column reorder, expandable rows, CSV export, a Figma library with Code Connect, and a React Native adapter built on `@tablekit/core`.
---
# Install
> Install tablekit with npm, the shadcn CLI, or an AI agent.
## npm
```sh
npm install @tablekit/react
```
Import the styles once, for example in your root layout:
```ts
```
## shadcn CLI
This copies the source into your project (`components/tablekit/`) and adds no runtime npm dependency except `zod`:
```sh
npx shadcn@latest add https://tablekit.amitpatjoshi.com/r/tablekit.json
```
Then import from your own folder:
```tsx
```
The registry item already includes the **shadcn preset**, so the table picks up your `--background`, `--primary`, `--radius` and other variables automatically.
## AI agent
Paste this into Claude Code, Cursor, Copilot, v0 or Lovable:
```text
Install tablekit (https://tablekit.amitpatjoshi.com/llms.txt) and replace the
table on the orders page with . Follow its AGENTS.md rules.
```
If your agent has the **shadcn MCP server**, it can find and install the registry item itself.
## Load Inter
tablekit is set in [Inter](https://github.com/rsms/inter). Numbers in the table use three Inter features: tabular figures (`"tnum"`), so columns of amounts, counts and dates line up digit for digit; a slashed zero (`"zero"`), so 0 never reads as O; and open digits (`"ss01"`), Inter's alternate 1, 3, 4, 6 and 9. The package doesn't bundle the font, so load it once:
```sh
npm install @fontsource-variable/inter
```
```ts
```
Or use the official CDN: ` `. If Inter isn't available, the table falls back to the system UI font.
## Use a design-system preset
If you use `styles.css`, add one preset import **after** it. Presets only remap `--tk-*` variables, so import order doesn't affect specificity.
```css
@import "@tablekit/react/styles.css";
@import "@tablekit/tokens/presets/shadcn.css"; /* or material3.css, carbon.css, radix.css */
```
See [Theming & design systems](https://tablekit.amitpatjoshi.com/guides/theming/).
## Requirements
- React 18 or 19. Everything is SSR-safe, and the build ships a `"use client"` banner for the Next.js App Router.
- Any evergreen browser. The component uses container queries, `color-mix()` and `:where()`.
---
# Schema mode
> The TableSchema reference, the declarative config behind .
` ` renders a complete table from a single JSON-serializable object. The schema is **strict**: unknown keys are rejected, so a mistyped or invented prop produces an error instead of quietly doing nothing.
```ts
const result = parseTableSchema(schema);
if (!result.success) console.error(result.errors);
// → ["columns.2.type: Invalid option: expected one of \"text\"|\"number\"|…"]
```
The JSON Schema version is available at [`/schema/v1.json`](https://tablekit.amitpatjoshi.com/schema/v1.json) and as `@tablekit/core/tablekit.schema.json`. Use it for editor autocompletion (`"$schema": "…/schema/v1.json"`) or for LLM structured output.
## Top level
| Key | Type | Default | Notes |
| --- | --- | --- | --- |
| `title` | string | | Toolbar heading and the table's accessible name. |
| `description` | string | | Muted text under the title. |
| `rowId` | string | `"id"` | Row field used as a stable id (for selection). |
| `columns` | Column[] | **required** | At least one. |
| `features` | object | see below | |
| `appearance` | object | see below | |
| `initialState` | `{ sort?, search? }` | | `sort: [{ id, desc }]`. |
| `bulkActions` | `{ id, label, tone? }[]` | | Shown in the selection bar. Requires `selection: "multi"`. |
| `emptyState` | `{ title, description? }` | | Shown when there's no data. This is different from "no results". |
### `features`
| Key | Default | |
| --- | --- | --- |
| `search` | `true` | Global search box |
| `columnFilters` | `true` | Filters popover and chips |
| `sorting` / `multiSort` | `true` / `true` | `Shift`+click adds a sort |
| `pagination` | `true` | |
| `pageSize` / `pageSizeOptions` | `10` / `[10,25,50,100]` | |
| `selection` | `"none"` | `"single"` or `"multi"` |
| `columnVisibility` | `true` | Show/hide checkboxes in the Columns menu |
| `columnReorder` | `true` | Drag handles in the Columns menu (or `↑`/`↓` on the handle) |
| `columnPinning` | `true` | Pin toggles in the Columns menu; `column.pinned` sets the initial state |
| `columnResize` | `true` | Drag, or use arrow keys on the handle |
| `stickyHeader` | `true` | Sticks inside `maxHeight` |
### `appearance`
| Key | Default | |
| --- | --- | --- |
| `density` | `"default"` | `"compact"` or `"comfortable"` |
| `variant` | `"plain"` | `"zebra"` or `"bordered"` |
| `responsive` | `"stack"` | `"scroll"` or `"priority"`. See [Responsive](https://tablekit.amitpatjoshi.com/responsive/) |
| `stackBelow` | `640` | Container width in px |
## Columns
Every column has `field`, `header` and `type`. `field` accepts dot paths such as `customer.name`.
| `type` | Renders | Default filter | Type-specific keys |
| --- | --- | --- | --- |
| `text` | Plain text | none | `format.prefix/suffix` |
| `number` | `Intl.NumberFormat`, right-aligned | range | `format.decimals/notation/style/unit` |
| `currency` | Money, right-aligned | range | `format.currency` (ISO 4217) |
| `date` | `` with a full timestamp on hover | range (date inputs) | `format.dateStyle` (`relative`!) / `timeStyle` |
| `badge` | Tone-colored pill with a dot, an icon, or icon only | select | `badge.tones`, `badge.labels`, `badge.defaultTone`, `badge.indicator` (`dot` · `icon` · `icon-only` · `none`), `badge.icons` (value → icon name), `badge.fill` (default `true`), `badge.stroke` (default `false`) |
| `avatar` | Initials or image, plus a subtitle. `logo: "inline"` (16px mark on the name's first line; `--tk-avatar-logo-inline-size` to change) or `logo: "circle"` (mark in a filled circle) for bank/merchant logos | none | `avatar.imageField`, `avatar.imageDarkField` (dark-theme variant, e.g. a light logo), `avatar.subtitleField`, `avatar.logo`, `avatar.logoFill` (`neutral` · `accent`) |
| `link` | Anchor | none | `link.hrefTemplate` (`/x/{id}`), `link.hrefField`, `link.external` |
| `boolean` | Check or dash icon | select | `format.trueLabel/falseLabel` |
| `actions` | Row menu (⋯) or inline icon buttons, never a row of text buttons | none | `actions: [{ id, label, tone?, icon?, disabled?, separator?, when? }]`, `actionsDisplay` (`menu` · `inline`) |
| `button` | One button inside the cell (Pay, Download, Retry) | none | `button: { id, label?, variant? (secondary · primary · ghost), tone?, icon?, when? }` |
Shared column keys: `id`, `sortable`, `filter` (`"text" | "select" | "range" | false`), `searchable`, `hideable`, `hidden`, `width`, `minWidth`, `maxWidth`, `pinned`, `priority` (1–5), `align`.
**Icons** come from Lucide, from two closed lists so a generated schema can't name an icon that doesn't exist:
- badge icons: `circle-check`, `check`, `circle-x`, `x`, `triangle-alert`, `circle-alert`, `info`, `circle-dashed`, `circle`, `circle-dot`, `circle-pause`, `clock`, `hourglass`, `loader` (spins), `refresh-cw`, `ban`, `lock`, `shield-check`, `arrow-up`, `arrow-down`, `arrow-right`, `undo-2`, `send`, `truck`, `star`, `zap`, `sparkles`, `eye`. With `indicator: "icon"` and no per-value icon, each tone uses its default: neutral `circle-dashed`, info `info`, success `circle-check`, warning `triangle-alert`, danger `circle-x`, accent `sparkles`.
- action icons: `eye`, `pencil`, `copy`, `download`, `upload`, `share-2`, `send`, `external-link`, `refresh-cw`, `undo-2`, `archive`, `trash-2`, `ban`, `lock`, `unlock`, `check`, `x`, `user-plus`, `mail`, `receipt`, `flag`, `star`. Every action needs one when `actionsDisplay` is `inline`.
An action's `when: { field, in }` shows it only on matching rows, e.g. Refund only where `status` is `paid`.
**Tones** are semantic: `neutral`, `info`, `success`, `warning`, `danger`, `accent`. They map to `--tk-tone-*` tokens, so each design system decides the actual colors. **Never put hex colors in a schema.**
## Events
```tsx
{}} // row menu
onBulkAction={(actionId, rows) => {}} // selection bar
onRowClick={(row) => {}}
onSelectionChange={(ids) => {}}
loading={isLoading}
error={error}
onRetry={refetch}
/>
```
## Example
---
# Composable API
> Build custom tables from useTable and the Table.* parts.
Use composable mode when you need custom cells, a different toolbar, or extra UI between the parts. `` is itself built from these same parts.
```tsx
type Project = { id: string; name: string; health: "good" | "risk"; budget: number };
const columns: ReactColumnDef[] = [
{ id: "name", header: "Project", pinned: true, width: 220 },
{
id: "health",
header: "Health",
filter: "select",
cell: ({ value }) => {String(value)} ,
},
{ id: "budget", header: "Budget", align: "end", filter: "range" },
];
export function Projects({ data }: { data: Project[] }) {
const table = useTable({ data, columns, selectionMode: "multi" });
return (
{/* only shows in the stacked layout */}
archive(table.selectedRows)}>Archive
open(row.original)} />
);
}
```
## `useTable(options)`
| Option | |
| --- | --- |
| `data`, `columns` | Required. |
| `getRowId(row, i)` | Defaults to `row.id`, then the index. |
| `selectionMode` | `"none"`, `"single"` or `"multi"` |
| `initialState` | Uncontrolled start: `sorting`, `globalFilter`, `columnFilters`, `pagination`, `rowSelection`, `columnVisibility`, `columnSizing` |
| `state` + `onStateChange` | Controlled. Any key you pass is owned by you. |
| `manualSorting` / `manualFiltering` / `manualPagination` + `rowCount` | Server mode. See [Server-side data](https://tablekit.amitpatjoshi.com/server-data/). |
| `enableMultiSort`, `enablePagination` | |
The returned instance has `rowModel` (`rows`, `filteredRows`, `allRows`, `pageCount`, `totalRows`), `state`, `selectedRows`, and actions such as `toggleSort`, `setGlobalFilter`, `setColumnFilter`, `setPageIndex`, `toggleRowSelected`, `toggleColumnVisibility` and `resizeColumn`.
## Column definition
Everything from `@tablekit/core`'s `ColumnDef` (`id`, `header`, `accessor`, `sortable`, `sortFn`, `filter`, `filterFn`, `searchable`, `hideable`, `resizable`, `width`, `minWidth`, `maxWidth`, `pinned`, `priority`, `align`), plus these React additions:
- `cell(ctx)` renders the cell. `ctx` has `value`, `row`, `column` and `table`.
- `text(ctx)` gives a plain-text version, used for card titles and checkbox labels.
- `optionLabel(value)` sets the label for select-filter options.
- `rangeType: "date"` uses date inputs for range filters.
- `isActions` marks a row-actions column. In the stacked layout it goes in the card header.
## Without React
`@tablekit/core` works in any framework:
```ts
const table = createTable({ data, columns });
table.subscribe(render);
table.setState((s) => ({ ...s, sorting: toggleSort(s.sorting, "name") }));
table.getRowModel().rows; // → current page
```
---
# Theming & design systems
> Tokens, dark mode, presets for shadcn/ui, Material 3, Carbon and Radix, and Figma via DTCG.
All styling reads CSS custom properties named `--tk-*`. The component CSS sits inside `:where()`, which gives it zero specificity. That means **you never need `!important`**: change a token, or add your own class.
## Token layers
1. **Primitives**, e.g. `--tk-neutral-100` and `--tk-blue-600`. Raw palette.
2. **Semantic**, e.g. `--tk-color-surface`, `--tk-color-row-selected`, `--tk-color-accent` and `--tk-tone-success-bg`. These are what components read.
3. **Base**, e.g. `--tk-font-*` (Inter by default; `--tk-font-feature-numeric` sets Inter's number features for every figure in the table, `"tnum" 1, "zero" 1, "ss01" 1` by default: tabular widths, slashed zero, open digits), `--tk-radius-*`, `--tk-space-cell-*`, `--tk-shadow-popover` and `--tk-motion-*`.
Override anything:
```css
:root {
--tk-color-accent: #0a7c66;
--tk-radius-md: 4px;
--tk-font-family: "IBM Plex Sans", sans-serif;
}
```
## Dark mode
The table follows `prefers-color-scheme` automatically. You can force a mode on any ancestor with `data-tk-theme="dark"`, `data-theme="dark"` or `class="dark"`, or on one table with ` `.
## Presets
A preset is a single CSS file that maps `--tk-*` tokens onto your design system's variables. When your design system switches theme, the table switches with it.
| Preset | Maps to | Import |
| --- | --- | --- |
| shadcn/ui | `--background`, `--muted`, `--primary`, `--ring`, `--radius` … | `@tablekit/tokens/presets/shadcn.css` |
| Material 3 | `--md-sys-color-*`, `--md-sys-shape-*` | `@tablekit/tokens/presets/material3.css` |
| IBM Carbon | `--cds-layer-*`, `--cds-text-*`, `--cds-tag-*` | `@tablekit/tokens/presets/carbon.css` |
| Radix Themes | `--gray-*`, `--accent-*`, `--radius-*` | `@tablekit/tokens/presets/radix.css` |
Using a different design system? Copy `shadcn.css` and replace the right-hand sides. It's about 30 lines.
## Figma & design tools
`@tablekit/tokens/tokens.json` follows the [W3C Design Tokens (DTCG)](https://tr.designtokens.org/format/) format. It has token sets `primitives`, `base`, `light` and `dark`, plus `$themes` for Light and Dark.
- **Tokens Studio for Figma:** load it with *Import → JSON*. Semantic tokens stay aliased to primitives.
- **Style Dictionary v4:** point `source` at the file to generate iOS, Android or other platform outputs.
Each CSS variable is named after its token path: `light.color.row-selected` becomes `--tk-color-row-selected`, and `light.tone.success.bg` becomes `--tk-tone-success-bg`.
## Stable class names
If you'd rather style with selectors, every part has a stable `tk-*` class, and state is exposed through data attributes:
```css
.tk-tr[data-selected] > .tk-td { … }
.tk-root[data-density="compact"] { … }
.tk-root[data-layout="stack"] { … }
.tk-th[data-sorted="desc"] { … }
.tk-badge[data-tone="danger"] { … }
```
---
# Responsive & mobile
> How tablekit adapts to narrow containers with stacked cards, scrolling, or priority columns.
tablekit responds to **its container's width**, not the viewport. This is done with `ResizeObserver` plus container queries, so a table in a narrow sidebar behaves like it would on a phone.
Choose a behavior with `appearance.responsive` (or `responsive` on `Table.Root`):
| Mode | Below the breakpoint | Good for |
| --- | --- | --- |
| `stack` (default) | Each row becomes a card. The first column is the title and the actions menu sits in the card header. A **Sort** select replaces the column headers. | Lists people scan and act on: orders, members, tickets |
| `priority` | Columns drop out by `priority` (1 = keep, 5 = drop first) at 1024 / 840 / 680 / 520px | Dense operational data: deployments, logs |
| `scroll` | Horizontal scroll, with pinned columns kept visible | Spreadsheets and comparisons |
`stackBelow` (default `640`) sets the breakpoint in container pixels.
## Touch
On `pointer: coarse` devices, buttons, inputs, page links and menu items are at least **44 px** tall, checkboxes are larger, and the resize handle hit area is wider.
## Try it
Resize your browser, or use the width switcher in the [Playground](https://tablekit.amitpatjoshi.com/playground/).
## Native mobile
`@tablekit/core` has no DOM dependency. It sorts, filters, paginates and selects plain arrays, which makes it the foundation for a future React Native adapter. The stacked-card layout is designed so it can be rebuilt with `FlatList`.
---
# Accessibility
> What tablekit does for keyboard, screen reader and low-vision users, and what you still need to do.
Target: **WCAG 2.2 AA**. Every build runs [axe-core](https://github.com/dequelabs/axe-core) in CI against the table, its states and the composable API.
## What you get
- A **semantic ``** with ``, `aria-sort` on sorted headers, and `aria-rowcount` / `aria-rowindex` so screen readers know the full size across pages. In the stacked layout, rows become a `` of cards with `` field pairs instead of CSS-reflowed table cells, which lose their semantics in several browsers.
- **Keyboard:**
- `Tab` reaches every control.
- Arrow keys, `Home` and `End` move between cells. When a cell has a link or button, focus goes to it.
- `Enter` on a cell runs `onRowClick`.
- `Shift`+click or `Shift`+`Enter` on a header adds a secondary sort.
- Resize handles are focusable separators you move with `←`/`→` (hold `Shift` for bigger steps). Double-click resets the width.
- **Announcements** through a polite live region: sort changes ("Sorted by Total, descending"), result counts while searching, page changes ("Page 2 of 5"), and selection counts.
- **Menus and popovers:** focus moves in when they open and returns to the trigger on close. `Escape` closes them. Row menus support arrow keys, `Home` and `End`.
- **Labels:** every checkbox has a meaningful name ("Select Ava Patel"). The select-all checkbox shows a mixed state. Icon-only buttons have `aria-label`s.
- **Visual:**
- Tokens meet 4.5:1 text contrast in light and dark.
- Focus rings are always visible (`:focus-visible`).
- Badges pair a dot with text, so status doesn't rely on color alone.
- `forced-colors` (Windows High Contrast) is supported.
- `prefers-reduced-motion` turns off animation.
## What you still own
- **An accessible name.** Pass `title` in the schema, or `aria-label` on `Table.Root`.
- **Custom cells.** If you render buttons or links yourself, give them names.
- **Contrast after theming.** If you override tokens, check them. The Playground is a quick way to preview.
- **Localization.** Pass a `labels` object to translate every string, including the announcements:
```tsx
`${n} ausgewählt`,
sortedAsc: (c) => `Sortiert nach ${c}, aufsteigend`,
}}
…
/>
```
---
# Server-side data
> Sort, filter and paginate on the server with controlled state.
For large datasets, let the server do the work. Set `manual` (schema mode) or the `manual*` options (composable mode), control the state, and pass `rowCount`.
```tsx
export function Orders() {
const [state, setState] = useState(() =>
createInitialState({ pagination: { pageIndex: 0, pageSize: 25 } }),
);
// Your fetching library of choice: TanStack Query, SWR, RSC, …
const { data, isLoading, error, refetch } = useOrders({
page: state.pagination.pageIndex,
size: state.pagination.pageSize,
sort: state.sorting, // [{ id: "created", desc: true }]
q: state.globalFilter,
filters: state.columnFilters, // [{ id: "status", value: ["paid"] }]
});
return (
);
}
```
While `loading` is true, current rows stay visible (dimmed) with a progress bar on top. On the first load you see skeleton rows instead.
Filter values arrive as plain JSON (`string` for text, `string[]` for select, `{ min, max }` for range), so you can map them to query parameters as they are.
---
# Agent guide
> How AI coding agents find, install and correctly use tablekit, and how to make your agent better at it.
tablekit is designed so an agent can build a correct table in one pass. It gives agents five things:
| Surface | URL / path | For |
| --- | --- | --- |
| `llms.txt` | [`/llms.txt`](https://tablekit.amitpatjoshi.com/llms.txt) | A short index agents read first |
| `llms-full.txt` | [`/llms-full.txt`](https://tablekit.amitpatjoshi.com/llms-full.txt) | The full docs, the JSON Schema and every recipe in one file |
| JSON Schema | [`/schema/v1.json`](https://tablekit.amitpatjoshi.com/schema/v1.json) | Validating or constraining generated configs |
| shadcn registry | [`/r/tablekit.json`](https://tablekit.amitpatjoshi.com/r/tablekit.json) | One-command install, including through the shadcn MCP server |
| `AGENTS.md` | shipped in the npm package and the registry | Rules to follow inside a user's codebase |
## Rules for agents
These rules are in `AGENTS.md`, so an agent sees them after installing.
1. **Prefer schema mode.** Use ` ` unless the user needs custom cell markup.
2. **Validate.** Call `parseTableSchema(schema)` from `@tablekit/core/schema` and fix every reported error. The schema is strict, so unknown keys are errors.
3. **Colors come from tokens, not schemas.** Use badge `tones` (`neutral`, `info`, `success`, `warning`, `danger`, `accent`). To match a design system, import a preset.
4. **Don't restyle with utility classes.** Override `--tk-*` tokens instead.
5. **Always give the table a name:** set `title` or `aria-label`.
6. **Keep data raw.** Pass numbers, ISO dates and enum strings, and let `type` and `format` handle display. Don't pre-format `"$1,234"`.
7. **Server data** uses `manual`, `rowCount`, `state` and `onStateChange`. Don't slice pages on the client when the API already paginates.
8. **Import styles once:** `import "@tablekit/react/styles.css"`.
## Constrained generation
Because the schema is plain JSON Schema, you can make an LLM produce **only valid tables** through structured output or tool use:
```ts
// e.g. with the Claude API: a tool whose input_schema is the TableSchema
const tools = [{
name: "render_table",
description: "Render a data table in the UI",
input_schema: schema,
}];
```
Your app then renders ` `. This also works well for generative UI inside agent products.
## Install with the shadcn MCP server
If your agent has the [shadcn MCP server](https://ui.shadcn.com/docs/mcp), register tablekit in `components.json`:
```json
{
"registries": {
"@tablekit": "https://tablekit.amitpatjoshi.com/r/{name}.json"
}
}
```
Then just ask: *"Add the tablekit data table from the @tablekit registry and use it for the users page."*
## Tell your agent about tablekit
Add one line to your project's `CLAUDE.md`, `AGENTS.md` or `.cursorrules`:
```md
Tables: use tablekit. Read https://tablekit.amitpatjoshi.com/llms.txt before writing table code.
```
---
# Recipes
Each recipe is a prompt and the schema a correct answer contains.
## Bank settlements (INR)
Prompt: Build a merchant settlements table for a payments dashboard: settlement id (pinned), merchant, destination bank with its compact 16px logo mark (avatar logo: inline, account number as subtitle, and a lighter variant of the mark for dark mode via imageDarkField), payment rail badge (UPI=accent, IMPS=info, NEFT=neutral, RTGS=warning), amount in INR (en-IN locale), status badge with icons (settled=success circle-check, processing=info spinning loader, on_hold=warning circle-pause, failed=danger circle-x) and settled time (relative). A "Retry" button cell only on failed rows, and a row ⋯ menu with View details, Download receipt, Copy UTR (never a row of text buttons). On narrow screens drop merchant, then settled time, rather than scrolling. Multi-select with a "Download report" bulk action. Newest first. Use tablekit's from @tablekit/react (docs: https://tablekit.amitpatjoshi.com/llms.txt). Validate the schema with parseTableSchema from @tablekit/core/schema. Don't hardcode colors; use badge tones.
```json
{
"title": "Settlements",
"description": "Payouts to merchant bank accounts.",
"columns": [
{
"field": "id",
"header": "Settlement",
"pinned": true,
"width": 134
},
{
"field": "merchant",
"header": "Merchant",
"priority": 3,
"width": 168
},
{
"field": "bank",
"header": "Bank",
"type": "avatar",
"avatar": {
"imageField": "bankLogo",
"imageDarkField": "bankLogoDark",
"subtitleField": "account",
"logo": "inline"
},
"width": 212
},
{
"field": "method",
"header": "Rail",
"type": "badge",
"badge": {
"tones": {
"UPI": "accent",
"IMPS": "info",
"NEFT": "neutral",
"RTGS": "warning"
}
},
"width": 96
},
{
"field": "amount",
"header": "Amount",
"type": "currency",
"format": {
"currency": "INR",
"locale": "en-IN"
},
"width": 140
},
{
"field": "status",
"header": "Status",
"type": "badge",
"badge": {
"tones": {
"settled": "success",
"processing": "info",
"on_hold": "warning",
"failed": "danger"
},
"icons": {
"settled": "circle-check",
"processing": "loader",
"on_hold": "circle-pause",
"failed": "circle-x"
}
},
"width": 130
},
{
"field": "settledAt",
"header": "Settled",
"type": "date",
"format": {
"dateStyle": "relative"
},
"priority": 2,
"width": 116
},
{
"field": "retry",
"header": "Retry",
"type": "button",
"button": {
"id": "retry",
"label": "Retry",
"icon": "refresh-cw",
"when": {
"field": "status",
"in": [
"failed"
]
}
}
},
{
"field": "actions",
"header": "Actions",
"type": "actions",
"actions": [
{
"id": "view",
"label": "View details",
"icon": "eye"
},
{
"id": "receipt",
"label": "Download receipt",
"icon": "download"
},
{
"id": "copy",
"label": "Copy UTR",
"icon": "copy"
},
{
"id": "hold",
"label": "Put on hold",
"icon": "lock",
"when": {
"field": "status",
"in": [
"processing"
]
},
"separator": true
}
]
}
],
"features": {
"selection": "multi"
},
"appearance": {
"responsive": "priority"
},
"initialState": {
"sort": [
{
"id": "settledAt",
"desc": true
}
]
},
"bulkActions": [
{
"id": "report",
"label": "Download report"
}
]
}
```
## Invoices / orders
Prompt: Add an invoices table to the billing page. Columns: invoice number (links to /invoices/{id}), customer with avatar and email, status badge with icons (paid=success circle-check, pending=warning clock, overdue=danger triangle-alert, refunded=neutral undo-2, draft=info circle-dashed), plan, total in USD, created date (relative). Allow multi-select with an "Export CSV" bulk action and a row menu with "View" and "Refund" (destructive). Sort by created, newest first. Use tablekit's from @tablekit/react (docs: https://tablekit.amitpatjoshi.com/llms.txt). Validate the schema with parseTableSchema from @tablekit/core/schema. Don't hardcode colors; use badge tones.
```json
{
"title": "Invoices",
"description": "All invoices across workspaces.",
"columns": [
{
"field": "id",
"header": "Invoice",
"type": "link",
"link": {
"hrefTemplate": "/invoices/{id}"
},
"pinned": true,
"width": 132
},
{
"field": "customer.name",
"header": "Customer",
"type": "avatar",
"avatar": {
"subtitleField": "customer.email"
},
"width": 220
},
{
"field": "status",
"header": "Status",
"type": "badge",
"badge": {
"tones": {
"paid": "success",
"pending": "warning",
"overdue": "danger",
"refunded": "neutral",
"draft": "info"
},
"icons": {
"paid": "circle-check",
"pending": "clock",
"overdue": "triangle-alert",
"refunded": "undo-2",
"draft": "circle-dashed"
}
},
"width": 130
},
{
"field": "plan",
"header": "Plan",
"type": "text",
"filter": "select",
"priority": 3,
"width": 104
},
{
"field": "total",
"header": "Total",
"type": "currency",
"format": {
"currency": "USD"
},
"width": 120
},
{
"field": "created",
"header": "Created",
"type": "date",
"format": {
"dateStyle": "relative"
},
"priority": 2,
"width": 124
},
{
"field": "actions",
"header": "Actions",
"type": "actions",
"actions": [
{
"id": "view",
"label": "View"
},
{
"id": "refund",
"label": "Refund",
"tone": "danger"
}
]
}
],
"features": {
"selection": "multi",
"pageSize": 10
},
"initialState": {
"sort": [
{
"id": "created",
"desc": true
}
]
},
"bulkActions": [
{
"id": "export",
"label": "Export CSV"
}
]
}
```
## Team members admin
Prompt: Build a team members admin table: member (avatar + email), role badge (owner=accent, admin=info, others neutral), team (filterable), status (active=success, invited=warning, suspended=danger), MFA enabled (boolean), last active (relative). Compact density, zebra rows. Row menu: "Change role", "Remove" (destructive). Use tablekit's from @tablekit/react (docs: https://tablekit.amitpatjoshi.com/llms.txt). Validate the schema with parseTableSchema from @tablekit/core/schema. Don't hardcode colors; use badge tones.
```json
{
"title": "Members",
"columns": [
{
"field": "name",
"header": "Member",
"type": "avatar",
"avatar": {
"subtitleField": "email"
},
"width": 260
},
{
"field": "role",
"header": "Role",
"type": "badge",
"badge": {
"tones": {
"owner": "accent",
"admin": "info"
},
"defaultTone": "neutral"
}
},
{
"field": "team",
"header": "Team",
"filter": "select",
"priority": 2
},
{
"field": "status",
"header": "Status",
"type": "badge",
"badge": {
"tones": {
"active": "success",
"invited": "warning",
"suspended": "danger"
}
}
},
{
"field": "mfa",
"header": "MFA",
"type": "boolean",
"format": {
"trueLabel": "Enabled",
"falseLabel": "Off"
},
"priority": 3,
"width": 90
},
{
"field": "lastActive",
"header": "Last active",
"type": "date",
"format": {
"dateStyle": "relative"
},
"priority": 2
},
{
"field": "actions",
"header": "Actions",
"type": "actions",
"actions": [
{
"id": "role",
"label": "Change role"
},
{
"id": "remove",
"label": "Remove",
"tone": "danger"
}
]
}
],
"features": {
"selection": "multi",
"pageSize": 10
},
"appearance": {
"density": "compact",
"variant": "zebra"
},
"bulkActions": [
{
"id": "remove",
"label": "Remove",
"tone": "danger"
}
]
}
```
## Deployments / CI runs
Prompt: Show recent deployments: id (monospace link to /deployments/{id}), service, environment badge (production=accent, staging=info, preview=neutral), status (ready=success, building=warning, failed=danger, canceled=neutral), commit message, author, duration in seconds (unit "second"), created (relative). On mobile, drop low-priority columns instead of stacking cards. Use tablekit's from @tablekit/react (docs: https://tablekit.amitpatjoshi.com/llms.txt). Validate the schema with parseTableSchema from @tablekit/core/schema. Don't hardcode colors; use badge tones.
```json
{
"title": "Deployments",
"columns": [
{
"field": "id",
"header": "Deployment",
"type": "link",
"link": {
"hrefTemplate": "/deployments/{id}"
},
"width": 130
},
{
"field": "service",
"header": "Service",
"filter": "select",
"width": 110
},
{
"field": "environment",
"header": "Env",
"type": "badge",
"badge": {
"tones": {
"production": "accent",
"staging": "info",
"preview": "neutral"
}
},
"width": 130
},
{
"field": "status",
"header": "Status",
"type": "badge",
"badge": {
"tones": {
"ready": "success",
"building": "warning",
"failed": "danger",
"canceled": "neutral"
}
},
"width": 120
},
{
"field": "commit",
"header": "Commit",
"priority": 3,
"width": 220
},
{
"field": "author",
"header": "Author",
"type": "avatar",
"priority": 4,
"width": 180
},
{
"field": "duration",
"header": "Duration",
"type": "number",
"format": {
"unit": "second"
},
"priority": 2,
"width": 110
},
{
"field": "created",
"header": "Created",
"type": "date",
"format": {
"dateStyle": "relative"
},
"width": 130
}
],
"appearance": {
"responsive": "priority"
},
"initialState": {
"sort": [
{
"id": "created",
"desc": true
}
]
}
}
```
## CRM leads pipeline
Prompt: Create a leads table for a CRM: lead (avatar with company as subtitle), stage badge (new=info, qualified=accent, proposal=warning, won=success, lost=neutral), deal value in EUR with compact notation, owner, next step date, last contacted (relative). Single selection opens a side panel via onRowClick. Use tablekit's from @tablekit/react (docs: https://tablekit.amitpatjoshi.com/llms.txt). Validate the schema with parseTableSchema from @tablekit/core/schema. Don't hardcode colors; use badge tones.
```json
{
"title": "Leads",
"columns": [
{
"field": "name",
"header": "Lead",
"type": "avatar",
"avatar": {
"subtitleField": "company"
}
},
{
"field": "stage",
"header": "Stage",
"type": "badge",
"badge": {
"tones": {
"new": "info",
"qualified": "accent",
"proposal": "warning",
"won": "success",
"lost": "neutral"
}
}
},
{
"field": "value",
"header": "Value",
"type": "currency",
"format": {
"currency": "EUR",
"notation": "compact"
}
},
{
"field": "owner",
"header": "Owner",
"filter": "select",
"priority": 3
},
{
"field": "nextStep",
"header": "Next step",
"type": "date",
"format": {
"dateStyle": "medium"
},
"priority": 2
},
{
"field": "lastContacted",
"header": "Last contacted",
"type": "date",
"format": {
"dateStyle": "relative"
},
"priority": 4
}
],
"features": {
"selection": "single"
}
}
```
## File browser
Prompt: Make a file list: name (link to the file URL in `url`, opens in a new tab), owner, size in kilobytes (unit "kilobyte", compact), type (select filter), shared (boolean), modified (relative). No pagination; sticky header inside a 480px scroll area. Use tablekit's from @tablekit/react (docs: https://tablekit.amitpatjoshi.com/llms.txt). Validate the schema with parseTableSchema from @tablekit/core/schema. Don't hardcode colors; use badge tones.
```json
{
"title": "Files",
"columns": [
{
"field": "name",
"header": "Name",
"type": "link",
"link": {
"hrefField": "url",
"external": true
}
},
{
"field": "owner",
"header": "Owner",
"priority": 3
},
{
"field": "size",
"header": "Size",
"type": "number",
"format": {
"unit": "kilobyte",
"notation": "compact"
}
},
{
"field": "type",
"header": "Type",
"filter": "select",
"priority": 2
},
{
"field": "shared",
"header": "Shared",
"type": "boolean"
},
{
"field": "modified",
"header": "Modified",
"type": "date",
"format": {
"dateStyle": "relative"
}
}
],
"features": {
"pagination": false
}
}
```
## Product inventory
Prompt: Inventory table: SKU (pinned), product name, category (select filter), stock (number, range filter), price in USD, stock status badge derived server-side (in_stock=success, low=warning, out=danger), updated date. Bordered variant, comfortable density. Bulk actions "Restock" and "Archive". Use tablekit's from @tablekit/react (docs: https://tablekit.amitpatjoshi.com/llms.txt). Validate the schema with parseTableSchema from @tablekit/core/schema. Don't hardcode colors; use badge tones.
```json
{
"title": "Inventory",
"columns": [
{
"field": "sku",
"header": "SKU",
"pinned": true,
"width": 120
},
{
"field": "name",
"header": "Product",
"width": 240
},
{
"field": "category",
"header": "Category",
"filter": "select"
},
{
"field": "stock",
"header": "Stock",
"type": "number"
},
{
"field": "price",
"header": "Price",
"type": "currency",
"format": {
"currency": "USD"
}
},
{
"field": "stockStatus",
"header": "Availability",
"type": "badge",
"badge": {
"tones": {
"in_stock": "success",
"low": "warning",
"out": "danger"
},
"labels": {
"in_stock": "In stock",
"low": "Low",
"out": "Out of stock"
}
}
},
{
"field": "updated",
"header": "Updated",
"type": "date",
"priority": 3
}
],
"features": {
"selection": "multi"
},
"appearance": {
"variant": "bordered",
"density": "comfortable"
},
"bulkActions": [
{
"id": "restock",
"label": "Restock"
},
{
"id": "archive",
"label": "Archive",
"tone": "danger"
}
]
}
```
## Support tickets queue
Prompt: Support queue: ticket id (link /tickets/{id}), subject, requester (avatar), priority badge (urgent=danger, high=warning, normal=neutral, low=info), status, assignee, SLA due date (short date + short time). Default sort by priority then due date (multi-sort). Use tablekit's from @tablekit/react (docs: https://tablekit.amitpatjoshi.com/llms.txt). Validate the schema with parseTableSchema from @tablekit/core/schema. Don't hardcode colors; use badge tones.
```json
{
"title": "Tickets",
"columns": [
{
"field": "id",
"header": "Ticket",
"type": "link",
"link": {
"hrefTemplate": "/tickets/{id}"
},
"width": 110
},
{
"field": "subject",
"header": "Subject",
"width": 280
},
{
"field": "requester",
"header": "Requester",
"type": "avatar",
"priority": 3
},
{
"field": "priority",
"header": "Priority",
"type": "badge",
"badge": {
"tones": {
"urgent": "danger",
"high": "warning",
"normal": "neutral",
"low": "info"
}
}
},
{
"field": "status",
"header": "Status",
"type": "badge",
"badge": {
"tones": {
"open": "info",
"pending": "warning",
"on_hold": "neutral",
"solved": "success"
}
}
},
{
"field": "assignee",
"header": "Assignee",
"filter": "select",
"priority": 2
},
{
"field": "due",
"header": "SLA due",
"type": "date",
"format": {
"dateStyle": "short",
"timeStyle": "short"
}
}
],
"initialState": {
"sort": [
{
"id": "priority",
"desc": false
},
{
"id": "due",
"desc": false
}
]
}
}
```
## Audit log
Prompt: Read-only audit log: timestamp (medium date + time), actor (avatar), action (text filter), target, IP address, result badge (success=success, denied=danger). Compact, no selection, no column hiding, 50 rows per page. Server-side pagination with rowCount from the API. Use tablekit's from @tablekit/react (docs: https://tablekit.amitpatjoshi.com/llms.txt). Validate the schema with parseTableSchema from @tablekit/core/schema. Don't hardcode colors; use badge tones.
```json
{
"title": "Audit log",
"columns": [
{
"field": "at",
"header": "Time",
"type": "date",
"format": {
"dateStyle": "medium",
"timeStyle": "medium"
},
"width": 200
},
{
"field": "actor",
"header": "Actor",
"type": "avatar"
},
{
"field": "action",
"header": "Action",
"filter": "text"
},
{
"field": "target",
"header": "Target",
"priority": 2
},
{
"field": "ip",
"header": "IP",
"priority": 4
},
{
"field": "result",
"header": "Result",
"type": "badge",
"badge": {
"tones": {
"success": "success",
"denied": "danger"
}
}
}
],
"features": {
"columnVisibility": false,
"pageSize": 50,
"pageSizeOptions": [
50,
100,
200
]
},
"appearance": {
"density": "compact"
}
}
```
## API keys
Prompt: API keys settings table: name, key prefix (e.g. "sk_live_…a1b2"), scopes, created, last used (relative), expires (medium date), status (active=success, expired=neutral, revoked=danger). Row menu: "Roll key", "Revoke" (destructive). Empty state: "No API keys yet" / "Create a key to access the API.". Use tablekit's from @tablekit/react (docs: https://tablekit.amitpatjoshi.com/llms.txt). Validate the schema with parseTableSchema from @tablekit/core/schema. Don't hardcode colors; use badge tones.
```json
{
"title": "API keys",
"columns": [
{
"field": "name",
"header": "Name"
},
{
"field": "prefix",
"header": "Key",
"searchable": false
},
{
"field": "scopes",
"header": "Scopes",
"priority": 3
},
{
"field": "lastUsed",
"header": "Last used",
"type": "date",
"format": {
"dateStyle": "relative"
}
},
{
"field": "expires",
"header": "Expires",
"type": "date",
"priority": 2
},
{
"field": "status",
"header": "Status",
"type": "badge",
"badge": {
"tones": {
"active": "success",
"expired": "neutral",
"revoked": "danger"
}
}
},
{
"field": "actions",
"header": "Actions",
"type": "actions",
"actions": [
{
"id": "roll",
"label": "Roll key"
},
{
"id": "revoke",
"label": "Revoke",
"tone": "danger"
}
]
}
],
"features": {
"search": false,
"columnFilters": false,
"pagination": false
},
"emptyState": {
"title": "No API keys yet",
"description": "Create a key to access the API."
}
}
```
## Analytics: top pages
Prompt: Analytics "Top pages" table: path (link), views (compact number), unique visitors (compact), bounce rate (percent, 1 decimal), avg. time on page (unit "second"), conversion (percent). Sorted by views desc, 25 per page, no search. Use tablekit's from @tablekit/react (docs: https://tablekit.amitpatjoshi.com/llms.txt). Validate the schema with parseTableSchema from @tablekit/core/schema. Don't hardcode colors; use badge tones.
```json
{
"title": "Top pages",
"columns": [
{
"field": "path",
"header": "Page",
"type": "link",
"link": {
"hrefField": "url"
}
},
{
"field": "views",
"header": "Views",
"type": "number",
"format": {
"notation": "compact"
}
},
{
"field": "visitors",
"header": "Visitors",
"type": "number",
"format": {
"notation": "compact"
},
"priority": 2
},
{
"field": "bounce",
"header": "Bounce",
"type": "number",
"format": {
"style": "percent",
"decimals": 1
},
"priority": 3
},
{
"field": "avgTime",
"header": "Avg. time",
"type": "number",
"format": {
"unit": "second"
},
"priority": 4
},
{
"field": "conversion",
"header": "Conv.",
"type": "number",
"format": {
"style": "percent",
"decimals": 1
}
}
],
"features": {
"search": false,
"pageSize": 25
},
"initialState": {
"sort": [
{
"id": "views",
"desc": true
}
]
}
}
```
---
# TableSchema (JSON Schema)
```json
{
"$id": "https://tablekit.amitpatjoshi.com/schema/v1.json",
"title": "tablekit TableSchema v1",
"description": "Declarative config for from @tablekit/react.",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"$schema": {
"type": "string"
},
"version": {
"default": 1,
"type": "number",
"const": 1
},
"title": {
"description": "Shown in the toolbar and used as the accessible name.",
"type": "string"
},
"description": {
"type": "string"
},
"rowId": {
"description": "Row field used as a stable id. Default `id`.",
"type": "string"
},
"columns": {
"minItems": 1,
"type": "array",
"items": {
"type": "object",
"properties": {
"field": {
"type": "string",
"minLength": 1,
"description": "Row property to read. Dot paths are supported: `customer.name`."
},
"header": {
"type": "string",
"description": "Visible column label."
},
"type": {
"default": "text",
"description": "Controls formatting, alignment, sorting and the default filter.",
"type": "string",
"enum": [
"text",
"number",
"currency",
"date",
"badge",
"avatar",
"link",
"boolean",
"actions",
"button"
]
},
"id": {
"description": "Defaults to `field`. Must be unique.",
"type": "string"
},
"sortable": {
"description": "Default true (false for `actions`).",
"type": "boolean"
},
"filter": {
"description": "Column filter UI. Defaults: badge/boolean → select, number/currency/date → range, others → none.",
"anyOf": [
{
"type": "string",
"enum": [
"text",
"select",
"range"
]
},
{
"type": "boolean",
"const": false
}
]
},
"searchable": {
"description": "Include in global search. Default true.",
"type": "boolean"
},
"hideable": {
"description": "Show in the column visibility menu. Default true.",
"type": "boolean"
},
"hidden": {
"description": "Start hidden.",
"type": "boolean"
},
"width": {
"description": "Initial width in px.",
"type": "integer",
"exclusiveMinimum": 0,
"maximum": 9007199254740991
},
"minWidth": {
"type": "integer",
"exclusiveMinimum": 0,
"maximum": 9007199254740991
},
"maxWidth": {
"type": "integer",
"exclusiveMinimum": 0,
"maximum": 9007199254740991
},
"pinned": {
"description": "Initially pinned to the start edge (sticky when scrolling horizontally). Users can change it when features.columnPinning is on.",
"type": "boolean"
},
"priority": {
"description": "1 = always visible … 5 = first to hide on narrow screens (responsive=priority).",
"type": "integer",
"minimum": 1,
"maximum": 5
},
"align": {
"description": "Numbers default to end.",
"type": "string",
"enum": [
"start",
"center",
"end"
]
},
"format": {
"type": "object",
"properties": {
"locale": {
"description": "BCP 47, e.g. `en-US`, `de-DE`.",
"type": "string"
},
"currency": {
"description": "ISO 4217 code, e.g. `USD`.",
"type": "string",
"minLength": 3,
"maxLength": 3
},
"decimals": {
"type": "integer",
"minimum": 0,
"maximum": 8
},
"notation": {
"type": "string",
"enum": [
"standard",
"compact"
]
},
"style": {
"type": "string",
"enum": [
"decimal",
"percent"
]
},
"unit": {
"description": "Intl unit, e.g. `kilobyte`, `percent`.",
"type": "string"
},
"dateStyle": {
"type": "string",
"enum": [
"short",
"medium",
"long",
"relative"
]
},
"timeStyle": {
"type": "string",
"enum": [
"short",
"medium"
]
},
"trueLabel": {
"type": "string"
},
"falseLabel": {
"type": "string"
},
"prefix": {
"type": "string"
},
"suffix": {
"type": "string"
}
},
"additionalProperties": false
},
"badge": {
"description": "Only for type=badge.",
"type": "object",
"properties": {
"tones": {
"description": "Value → tone, e.g. { \"paid\": \"success\", \"overdue\": \"danger\" }.",
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "string",
"enum": [
"neutral",
"info",
"success",
"warning",
"danger",
"accent"
],
"description": "Semantic color. Maps to --tk-tone-* tokens; never a raw color."
}
},
"labels": {
"description": "Value → display label.",
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "string"
}
},
"defaultTone": {
"type": "string",
"enum": [
"neutral",
"info",
"success",
"warning",
"danger",
"accent"
],
"description": "Semantic color. Maps to --tk-tone-* tokens; never a raw color."
},
"fill": {
"description": "Tinted background. Default true. false + stroke = outline badge.",
"type": "boolean"
},
"stroke": {
"description": "1px border in the tone's colour. Default false. Combine with fill for a tinted, bordered badge.",
"type": "boolean"
},
"indicator": {
"description": "Leading mark. dot (default; \"icon\" when `icons` is set), icon = icon + label, icon-only = icon with the label as tooltip and screen-reader text, none = label only.",
"type": "string",
"enum": [
"dot",
"icon",
"icon-only",
"none"
]
},
"icons": {
"description": "Value → Lucide icon name, e.g. { \"settled\": \"circle-check\", \"processing\": \"loader\" }. Values without one use their tone's default icon.",
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "string",
"enum": [
"circle-check",
"check",
"circle-x",
"x",
"triangle-alert",
"circle-alert",
"info",
"circle-dashed",
"circle",
"circle-dot",
"circle-pause",
"clock",
"hourglass",
"loader",
"refresh-cw",
"ban",
"lock",
"shield-check",
"arrow-up",
"arrow-down",
"arrow-right",
"undo-2",
"send",
"truck",
"star",
"zap",
"sparkles",
"eye"
]
}
}
},
"additionalProperties": false
},
"avatar": {
"description": "Only for type=avatar. `field` is the display name.",
"type": "object",
"properties": {
"imageField": {
"description": "Row field holding the image URL.",
"type": "string"
},
"imageDarkField": {
"description": "Row field holding a dark-theme variant of the image (e.g. a light logo for dark backgrounds). Shown instead of imageField when the table is dark.",
"type": "string"
},
"subtitleField": {
"description": "Row field shown under the name.",
"type": "string"
},
"logo": {
"description": "Render the image as a brand logo (bank, merchant, provider) instead of a person: inline = 16px compact mark centred on the name's first line, no container; circle = mark centred in a filled circle.",
"type": "string",
"enum": [
"inline",
"circle"
]
},
"logoFill": {
"description": "Fill behind logo=circle. Default neutral.",
"type": "string",
"enum": [
"neutral",
"accent"
]
}
},
"additionalProperties": false
},
"link": {
"description": "Only for type=link.",
"type": "object",
"properties": {
"hrefField": {
"description": "Row field holding the URL.",
"type": "string"
},
"hrefTemplate": {
"description": "URL with {field} placeholders, e.g. `/orders/{id}`.",
"type": "string"
},
"external": {
"description": "Open in a new tab with rel=noopener.",
"type": "boolean"
}
},
"additionalProperties": false
},
"actions": {
"description": "Only for type=actions. Row menu items.",
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "Stable id passed to onAction / onBulkAction."
},
"label": {
"type": "string"
},
"tone": {
"description": "`danger` for destructive actions.",
"type": "string",
"enum": [
"neutral",
"danger"
]
},
"icon": {
"description": "Lucide icon name. Required for every action when actionsDisplay is inline.",
"type": "string",
"enum": [
"eye",
"pencil",
"copy",
"download",
"upload",
"share-2",
"send",
"external-link",
"refresh-cw",
"undo-2",
"archive",
"trash-2",
"ban",
"lock",
"unlock",
"check",
"x",
"user-plus",
"mail",
"receipt",
"flag",
"star"
]
},
"disabled": {
"description": "Shown but not selectable.",
"type": "boolean"
},
"separator": {
"description": "Draw a divider before this item (menu only).",
"type": "boolean"
},
"when": {
"description": "Only show for rows where row[field] is one of `in`, e.g. { \"field\": \"status\", \"in\": [\"paid\"] }.",
"type": "object",
"properties": {
"field": {
"type": "string"
},
"in": {
"minItems": 1,
"type": "array",
"items": {
"type": "string"
}
}
},
"required": [
"field",
"in"
],
"additionalProperties": false
}
},
"required": [
"id",
"label"
],
"additionalProperties": false
}
},
"actionsDisplay": {
"description": "Only for type=actions. menu (default) = ⋯ menu; inline = icon buttons (every action needs an icon). For one clear call to action per row, use a type=button column instead.",
"type": "string",
"enum": [
"menu",
"inline"
]
},
"button": {
"description": "Only for type=button: a single button inside the cell (e.g. Pay, Download, Retry).",
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "Action id passed to onAction(id, row)."
},
"label": {
"description": "Button text. Defaults to the cell value.",
"type": "string"
},
"variant": {
"description": "secondary (default, outlined) · primary (accent fill) · ghost (text only).",
"type": "string",
"enum": [
"secondary",
"primary",
"ghost"
]
},
"tone": {
"type": "string",
"enum": [
"neutral",
"danger"
]
},
"icon": {
"description": "Leading Lucide icon.",
"type": "string",
"enum": [
"eye",
"pencil",
"copy",
"download",
"upload",
"share-2",
"send",
"external-link",
"refresh-cw",
"undo-2",
"archive",
"trash-2",
"ban",
"lock",
"unlock",
"check",
"x",
"user-plus",
"mail",
"receipt",
"flag",
"star"
]
},
"when": {
"description": "Only render the button on rows where row[field] is one of `in`; other rows show nothing.",
"type": "object",
"properties": {
"field": {
"type": "string"
},
"in": {
"minItems": 1,
"type": "array",
"items": {
"type": "string"
}
}
},
"required": [
"field",
"in"
],
"additionalProperties": false
}
},
"required": [
"id"
],
"additionalProperties": false
}
},
"required": [
"field",
"header"
],
"additionalProperties": false
}
},
"features": {
"default": {},
"type": "object",
"properties": {
"search": {
"default": true,
"description": "Global search box.",
"type": "boolean"
},
"columnFilters": {
"default": true,
"type": "boolean"
},
"sorting": {
"default": true,
"type": "boolean"
},
"multiSort": {
"default": true,
"type": "boolean"
},
"pagination": {
"default": true,
"type": "boolean"
},
"pageSize": {
"default": 10,
"type": "integer",
"exclusiveMinimum": 0,
"maximum": 9007199254740991
},
"pageSizeOptions": {
"default": [
10,
25,
50,
100
],
"type": "array",
"items": {
"type": "integer",
"exclusiveMinimum": 0,
"maximum": 9007199254740991
}
},
"selection": {
"default": "none",
"type": "string",
"enum": [
"none",
"single",
"multi"
]
},
"columnVisibility": {
"default": true,
"type": "boolean"
},
"columnResize": {
"default": true,
"type": "boolean"
},
"columnReorder": {
"default": true,
"description": "Users can reorder columns from the column menu (drag or Alt+Arrow keys).",
"type": "boolean"
},
"columnPinning": {
"default": true,
"description": "Users can pin columns to the start edge from the column menu. `column.pinned` sets the initial state.",
"type": "boolean"
},
"stickyHeader": {
"default": true,
"type": "boolean"
}
},
"additionalProperties": false
},
"appearance": {
"default": {},
"type": "object",
"properties": {
"density": {
"default": "default",
"type": "string",
"enum": [
"compact",
"default",
"comfortable"
]
},
"variant": {
"default": "plain",
"type": "string",
"enum": [
"plain",
"zebra",
"bordered"
]
},
"responsive": {
"default": "stack",
"description": "Narrow-screen behavior. stack = cards, scroll = horizontal scroll, priority = drop low-priority columns.",
"type": "string",
"enum": [
"stack",
"scroll",
"priority"
]
},
"stackBelow": {
"default": 640,
"description": "Container width (px) below which `stack` switches to cards.",
"type": "integer",
"exclusiveMinimum": 0,
"maximum": 9007199254740991
}
},
"additionalProperties": false
},
"initialState": {
"type": "object",
"properties": {
"sort": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"desc": {
"default": false,
"type": "boolean"
}
},
"required": [
"id"
],
"additionalProperties": false
}
},
"search": {
"type": "string"
}
},
"additionalProperties": false
},
"bulkActions": {
"description": "Shown in the selection bar when rows are selected. Requires selection=multi.",
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "Stable id passed to onAction / onBulkAction."
},
"label": {
"type": "string"
},
"tone": {
"description": "`danger` for destructive actions.",
"type": "string",
"enum": [
"neutral",
"danger"
]
},
"icon": {
"description": "Lucide icon name. Required for every action when actionsDisplay is inline.",
"type": "string",
"enum": [
"eye",
"pencil",
"copy",
"download",
"upload",
"share-2",
"send",
"external-link",
"refresh-cw",
"undo-2",
"archive",
"trash-2",
"ban",
"lock",
"unlock",
"check",
"x",
"user-plus",
"mail",
"receipt",
"flag",
"star"
]
},
"disabled": {
"description": "Shown but not selectable.",
"type": "boolean"
},
"separator": {
"description": "Draw a divider before this item (menu only).",
"type": "boolean"
},
"when": {
"description": "Only show for rows where row[field] is one of `in`, e.g. { \"field\": \"status\", \"in\": [\"paid\"] }.",
"type": "object",
"properties": {
"field": {
"type": "string"
},
"in": {
"minItems": 1,
"type": "array",
"items": {
"type": "string"
}
}
},
"required": [
"field",
"in"
],
"additionalProperties": false
}
},
"required": [
"id",
"label"
],
"additionalProperties": false
}
},
"emptyState": {
"type": "object",
"properties": {
"title": {
"type": "string"
},
"description": {
"type": "string"
}
},
"required": [
"title"
],
"additionalProperties": false
}
},
"required": [
"columns"
],
"additionalProperties": false
}
```