X Data

Star on GitHubFeedback
X DataNative HTMLot-data-table

Data table

Records you narrow with a sentence, sort by pressing a heading (the column turns ink and the rows glide), and page at a folio, or read on as the table ends in the rest of itself.

Report an issue with Data table

Example

projects

The studio's projects
Harbour Lineweb2026£48,000
Salt & Ryeidentity2026£22,000
Common Groundweb2026£61,000
Quiet Hoursmotion2026£42,000
Glasshouseidentity2026£27,500
Blue Hourweb2026£54,000
Almanacidentity2026£30,000
Longshoremotion2026£36,000

1 to 8 of 24 projects

The studio's projects, by fee
Common Groundweb2026£61,000
Blue Hourweb2026£54,000
Harbour Lineweb2026£48,000
Quiet Hoursmotion2026£42,000
Signal Boxweb2025£40,500

5 of 24 projects

A filter that keeps nothing

projects

Print projects
No print projects.

0 projects

The last page
The studio's projects, last page
Undertowmotion2025£23,500
Blue Hourweb2026£54,000
Marramidentity2023£11,000
Signal Boxweb2025£40,500
Cindermotion2024£19,000
Almanacidentity2026£30,000
Fenweb2023£21,000
Longshoremotion2026£36,000

17 to 24 of 24 projects

Tail, all shown
Seven projects
Harbour Lineweb2026£48,000
Salt & Ryeidentity2026£22,000
Nightjarmotion2025£31,000
Kestrel Pressidentity2025£18,500
Low Tideweb2024£26,000
Common Groundweb2026£61,000
Paper Moonmotion2024£14,000

7 projects

Install

Run it in a project that has the shadcn CLI set up. It brings the 0nlyType base, button, pagination, select, and table along.

npx shadcn@latest add https://thedirectors.agency/ui/r/data-table.json

Usage

data-table-example.tsx
"use client"import { DataTable, type DataTableColumn, type DataTableFilter } from "@/components/ui/data-table"type Project = { name: string; kind: "web" | "identity" | "motion"; year: number; fee: number }// Fictional studio work.const projects: Project[] = [["Harbour Line", "web", 2026, 48000], ["Salt & Rye", "identity", 2026, 22000], ["Nightjar", "motion", 2025, 31000],["Kestrel Press", "identity", 2025, 18500], ["Low Tide", "web", 2024, 26000], ["Common Ground", "web", 2026, 61000],["Paper Moon", "motion", 2024, 14000], ["Field Notes", "identity", 2023, 9500], ["Orchard", "web", 2025, 37500],["Quiet Hours", "motion", 2026, 42000], ["Tin Roof", "identity", 2024, 12000], ["Northlight", "web", 2023, 29000],["Glasshouse", "identity", 2026, 27500], ["Slow Burn", "motion", 2023, 8000], ["Wayfarer", "web", 2024, 33000],["Linden", "identity", 2025, 16000], ["Undertow", "motion", 2025, 23500], ["Blue Hour", "web", 2026, 54000],["Marram", "identity", 2023, 11000], ["Signal Box", "web", 2025, 40500], ["Cinder", "motion", 2024, 19000],["Almanac", "identity", 2026, 30000], ["Fen", "web", 2023, 21000], ["Longshore", "motion", 2026, 36000],].map(([name, kind, year, fee]) => ({ name, kind, year, fee }) as Project)const pounds = new Intl.NumberFormat("en-GB", { style: "currency", currency: "GBP", maximumFractionDigits: 0 })const columns: DataTableColumn<Project>[] = [{ id: "name", header: "Project", value: (p) => p.name, primary: true },{ id: "kind", header: "Kind", value: (p) => p.kind },{ id: "year", header: "Year", value: (p) => p.year, numeric: true },{ id: "fee", header: "Fee", value: (p) => p.fee, cell: (p) => pounds.format(p.fee), numeric: true },]const filters: DataTableFilter<Project>[] = (["web", "identity", "motion"] as const).map((k) => ({ id: k, label: k, test: (p) => p.kind === k }))const byName = (p: Project) => p.nameexport default function Example() {return (<div className="grid w-full gap-16"><DataTable data={projects} columns={columns} filters={filters} noun="projects" caption="The studio's projects" getRowId={byName} defaultSort={{ id: "year", dir: -1 }} /><DataTable data={projects} columns={columns} noun="projects" caption="The studio's projects, by fee" getRowId={byName} defaultSort={{ id: "fee", dir: -1 }} variant="tail" pageSize={5} tableVariant="forte" /></div>)}

Contract

  • Underneath: table, pagination and select, composed; native controls throughout.
  • Anatomy: <div class="ot-data-table" data-variant="folio | tail"> holding .ot-data-table-filter (a sentence, "Show [all] projects", its chosen word a ot-select), the ot-table, and .ot-data-table-foot: .ot-data-table-count (role="status", "1 to 8 of 24 projects") and either the folio pager or, in a tail, .ot-data-table-more. Rows that come into view carry data-arriving and --k, their place in the turn.
  • The filter is a sentence, not a toolbar: the chosen word is italic, as yours, and the noun ends it. Narrowing returns to the first page and the rows that stay glide to their places, as the table's do on a re-sort; the sorted column is in ink (forte sets it loud). Pressing a heading sorts by it, a numeric column largest first; pressing again turns the order round.
  • variant: folio (the default): a page at a time; the count stands at the start of the foot line in pencil, the book's folio (the page over the total, pagination's folio) at its end. Turning a page keeps focus in the pager, and the new page's rows arrive in turn. tail: the table ends in the rest of itself, as the collapsible does: "and 16 more" is the control, straight under the last rule; the next rows arrive in turn under it, and once all are shown it says Show fewer.
  • Empty: a filter that keeps nothing says so in a line ("No print projects.") and offers "Show all projects"; there is no picture.
  • Keyboard: native buttons, links and the select. Reduced motion: the tempos fall to nothing, so rows simply appear.

Props

Props of Data table
PropTypeDefaultWhat it does
data / columnsT[] / DataTableColumn<T>[]The records, and the columns: id, header, value (what it sorts by and shows), and optionally cell (how it shows), numeric (right-aligned figures, largest first on the first press), primary (the row's name) and sortable (false to leave a heading unpressable).
captionstringNames the table for screen readers.
nounstring"rows"What the rows are, in the plural. It ends the filter's sentence (Show all projects) and the count (1 to 8 of 24 projects).
filters{ id; label; test }[]Ways to narrow the rows, offered as the chosen word of a sentence: Show [identity] projects. The label is lower case to sit in the sentence. all always comes first. Narrowing goes back to the first page, and the rows that stay glide to their new places.
filter / defaultFilter / onFilterChangestring"all"The filter, controlled or not.
defaultSort{ id: string; dir: 1 | -1 }The column the rows start sorted by. Pressing a heading sorts by it; pressing again turns the order round.
variant"folio" | "tail""folio"folio: a page at a time, the book's folio (the page over the total) at the end of the foot line, the count at its start. tail: the table ends in the rest of itself, as a collapsible does; "and 16 more" is the control, and the next rows arrive in turn under the last; once all are shown it says Show fewer.
page / defaultPage / onPageChangenumber1The page, controlled or not. In a tail, how many pages are shown.
pageSizenumber8Rows to a page.
pageHref(page: number) => string?page=nWhere a page link goes when opened in a new tab. A plain press turns the page in place and keeps focus in the pager.
getRowId(row, index) => stringthe indexA stable name for each row, so it keeps its place and glides through sorting and filtering.
tableVariant"ink" | "forte""ink"The table's own setting. forte sets the sorted column loud, and the loudness passes as you re-sort.

Craft

Where its move comes from

Where its move comes from
ComponentReferenceMove
Data table"Renaissance." two colours; the book's folioThe filter a sentence, the sorted column in ink, the count and the folio on the foot line
Tail data tableThe collapsible's tail; "the silence that heals" captionsThe table ends in the rest of itself: "and 16 more"

How it moves

How it moves
ComponentArticulationWhat moves
Data tableGlide, then arriveNarrowed or sorted, the rows glide; a new page's rows arrive in turn
Tail data tableArriveThe next rows arrive in turn under the last