Data Table
A typed data table with sorting, search and row selection, where rows glide into their new order.
| Actions | |||||
|---|---|---|---|---|---|
| paid | $1,840 | ||||
| pending | $420 | ||||
| paid | $96 | ||||
| failed | $2,400 | ||||
| paid | $780 | ||||
| refunded | $96 | ||||
| Total | $17,408 |
Installation
Usage
import { DataTable, type DataTableColumn } from "@/components/spectrumui/data-table"interface Invoice {
id: string
customer: string
status: "paid" | "pending" | "failed"
amount: number
}
const columns: DataTableColumn<Invoice>[] = [
{ id: "customer", header: "Customer", sortable: true, value: (row) => row.customer },
{ id: "status", header: "Status", sortable: true, value: (row) => row.status },
{ id: "amount", header: "Amount", sortable: true, numeric: true, value: (row) => row.amount },
]
<DataTable
data={invoices}
columns={columns}
rowId={(row) => row.id}
caption="Invoices from the last 30 days."
searchable
selectable
pageSize={10}
defaultSort={{ columnId: "amount", direction: "desc" }}
bulkActions={({ ids, clear }) => (
<button onClick={() => exportInvoices(ids).then(clear)}>Export</button>
)}
/>How it behaves
DataTable is generic over your row type, so cell, value and rowId all receive a typed row and never an any. It renders one real <table>: sortable headers carry aria-sort and a real button, selection uses real checkboxes with a mixed state on the header, disclosure buttons carry aria-expanded, and a single polite live region announces the row and selection counts.
The toolbar composes like a product screen: title on the leading side; filter pills, the optional search field and your own actions (the toolbar slot) on the trailing side. quickFilter renders one pill per distinct value of a column with a live count, so filtering a status is one tap instead of a typed query — search stays opt-in for the long lists where free text earns its place. The search mark is Iconly Light, an outline glyph: fill is reserved for a state that is already on. Focusing the field darkens that stroke and eases the input wider so the query has room, and the clear control blooms in — scale, opacity and blur — rather than popping onto the trailing edge. Filter pills share one sliding fill, so choosing a status is a transfer of the same chip, not a repaint. The checkbox fill grows into its box and the tick draws; selected counts and page ranges roll to the new figure. An empty body staggers its icon, title and action, because that screen appears once. Sorting is a three-step cycle — ascending, descending, off — so a table can always be put back in source order, and blanks stay at the bottom in both directions. Changing the sort moves each row to its new position on a critically damped spring rather than repainting the body, and the caret rotates between the two directions instead of swapping for a second glyph. Hovering a row stays a 100ms tint, because a row is hovered hundreds of times a session. Only the bulk-action bar, which appears once per selection, gets a spring. Under prefers-reduced-motion every one of those becomes instant, and each state keeps a static cue: a filled checkbox, an aria-sort value.
It is also built for the keyboard. The grid takes a single Tab stop and then moves a cursor rather than focus, so arrow keys walk the rows while the controls inside a row keep their own keys: Space selects, Shift with the arrows or a click takes the whole range, ⌘A takes the page, ⌘C copies the selection as TSV straight into a spreadsheet, and Escape clears. Select every row on a page and the table offers the rest of the matches instead of pretending six was what you meant.
Deleting is the one place the table lets motion take real time. Call remove() from the bulk bar or a row action and the rows sweep out to the start edge one after another, roughly a frame apart, accelerating away rather than easing to a stop — an undertow, not a blink. Each row is pulled the instant its own sweep ends, so the rows beneath begin closing the gap while the next one is still on its way out, and the wave drains down the table. onDelete fires once at the end with the ids, which is when you remove them from your own data. Under reduced motion the rows simply go.
API Reference
DataTable
| Prop | Type | Default | Description |
|---|---|---|---|
*data | readonly T[] | - | Rows to render |
*columns | DataTableColumn<T>[] | - | Column definitions, start to end |
*rowId | (row: T) => string | - | Stable identity per row; selection and disclosure are keyed on it |
rowLabel | (row: T) => string | - | Names a row for screen readers, e.g. on its checkbox. Defaults to the first cell |
variant | "default" | "bordered" | "striped" | "minimal" | "panel" | "default" | Surface treatment: frame, header fill and row separation |
density | "compact" | "default" | "relaxed" | "default" | Row height, cell padding and type size |
caption | string | - | Sentence describing the table for screen readers; never painted |
title | ReactNode | - | Heading at the start of the toolbar |
toolbar | ReactNode | - | Controls parked at the end of the toolbar |
searchable | boolean | false | Adds a search field on the toolbar’s trailing side. Reach for it on long free-text lists; enumerable columns read better as a quickFilter |
searchText | (row: T) => string | - | Override the haystack a query runs against |
quickFilter | { columnId: string; label?: string; getValue?: (row: T) => string; options?: { value: string; label?: ReactNode }[]; allLabel?: string } | - | One-tap value pills with live counts at the end of the toolbar; options default to the column’s distinct values |
defaultSort | DataTableSort | null | null | Sort the table starts on, when uncontrolled |
sort | DataTableSort | null | - | Controlled sort; pair with onSortChange |
onSortChange | (sort: DataTableSort | null) => void | - | Fires on every header click, including the one that clears the sort |
selectable | boolean | false | Adds the checkbox column and the header select-all |
selectedIds | string[] | - | Controlled selection; pair with onSelectedChange |
defaultSelectedIds | string[] | - | Rows selected on mount, when uncontrolled |
onSelectedChange | (ids: string[]) => void | - | Fires with the full selection whenever it changes |
bulkActions | (ctx: { ids: string[]; rows: T[]; clear: () => void; remove: () => void }) => ReactNode | - | Actions for the bar that rises over the rows once something is selected |
renderDetail | (row: T) => ReactNode | - | Panel revealed under a row by a leading disclosure button, one at a time |
rowActions | (row: T, actions: { remove: () => void }) => ReactNode | - | Trailing cell, revealed on row hover and on keyboard focus |
onDelete | (ids: string[]) => void | - | Fires once the removal wave has finished; drop the ids from your own data here |
pageSize | number | - | Rows per page. Omit to render every row |
loading | boolean | false | Swaps the body for skeleton rows and marks the table aria-busy |
skeletonRows | number | 5 | How many skeleton rows to show while loading |
emptyState | ReactNode | - | Replaces the built-in empty and no-results state |
onRowClick | (row: T) => void | - | Makes the row clickable and turns the first cell into its keyboard activator |
keyboardNavigation | boolean | true | Arrow-key cursor, Shift range selection and the ⌘A / ⌘C / Space shortcuts |
clipboard | boolean | true | Copy button in the bulk bar, and ⌘C, writing the selection as TSV |
totals | string[] | - | Column ids to sum in a footer that rolls to its new value as you filter |
resizableColumns | boolean | false | Drag a header’s trailing edge to resize it; the splitter also takes arrow keys |
pinFirstColumn | boolean | false | Keeps the leading cells in place, with a shadow, while the table scrolls sideways |
stickyHeader | boolean | false | Pins the header and lifts it with a shadow once the body scrolls |
maxHeight | number | string | - | Caps the scroll area, e.g. 360 or "60vh". Pairs with stickyHeader |
animate | boolean | true | Set false to drop the reorder, disclosure and selection motion |
className | string | - | Additional classes merged onto the outer wrapper |
DataTableColumn
A column renders through cell and sorts and searches through value. Give a column both when its cell renders markup: the table cannot compare a React element, so a sortable badge column needs the raw value behind it. With neither, the column falls back to reading row[id].
| Prop | Type | Default | Description |
|---|---|---|---|
*id | string | - | Stable key, and the property read off the row when value is absent |
*header | ReactNode | - | Header label |
cell | (row: T, index: number) => ReactNode | - | Rendered cell. Falls back to the column value, printed as text |
value | (row: T) => string | number | boolean | Date | null | undefined | - | The comparable, searchable value behind the cell |
sortable | boolean | false | Turns the header into a three-step sort button |
numeric | boolean | false | End-aligns the column and switches it to tabular figures |
align | "start" | "center" | "end" | "start" | Overrides the alignment numeric would otherwise pick |
width | number | string | - | Column width hint passed to the header cell |
hideBelow | "sm" | "md" | "lg" | - | Drops the column below this breakpoint instead of scrolling the table |
formatTotal | (sum: number) => string | - | Renders this column’s footer sum. Defaults to a grouped integer |
className | string | - | Classes merged onto every body cell in the column |
headerClassName | string | - | Classes merged onto the header cell |
Examples
Everything at once
A pipeline table with the whole surface turned on: a pinned first column and pinned header for a grid wider than its box, draggable column widths, a rolling totals footer that recounts as you filter, and the keyboard model. Click into the table and walk it with the arrow keys, and ⌘C puts what you picked on the clipboard as TSV.
| Havenpoint | lost | $138,000 | ||
| Copperline | proposal | $129,600 | ||
| Nordform | negotiation | $121,200 | ||
| Tidepool | won | $112,800 | ||
| Paperkite | lost | $104,400 | ||
| Arcfield | proposal | $96,000 | ||
| Loomstack | negotiation | $87,600 | ||
| Carbonline | won | $79,200 | ||
| Zoning | lost | $70,800 | ||
| Kirin Works | proposal | $62,400 | ||
| Medela Brasil | negotiation | $54,000 | ||
| Brightpath | won | $45,600 | ||
| Heliolab | lost | $37,200 | ||
| Fold Studio | proposal | $28,800 | ||
| Quillbase | negotiation | $20,400 | ||
| Northsail Group | won | $12,000 | ||
| Total | $1,200,000 |
- ↑ ↓Move the cursor
- ⇧ ↑ ↓Extend the selection
- SpaceSelect the row
- ⌘ ASelect the page
- ⌘ CCopy as TSV
- EscClear
Variants and density
Five surfaces and three densities. panel trades the border for a layered shadow so it reads as elevated on any background, minimal drops the frame for a table that sits inside an existing card, and compact is the density an operations tool wants when the screen has to hold thirty rows.
| api-gateway | iad1 | 1.3M | 92 ms |
| search-index | sfo1 | 268K | 148 ms |
| webhook-relay | sin1 | 77K | 221 ms |
| billing-worker | fra1 | 41K | 310 ms |
| mailer | iad1 | 9.8K | 64 ms |
Pinned header on a long, dense table
The shape an operations tool needs: compact density, stickyHeader and a maxHeight so the table scrolls inside its own box rather than pushing the page down. The header picks up a shadow the moment the body scrolls under it, and drops it again at the top. onRowClick makes the row clickable and turns its first cell into a real button, so opening a row does not become a mouse-only action.
| info | seat.added | 12 ms | |
| info | invoice.finalized | 49 ms | |
| warn | key.rotated | 86 ms | |
| error | webhook.retried | 123 ms | |
| info | member.invited | 160 ms | |
| info | export.requested | 197 ms | |
| info | plan.changed | 234 ms | |
| warn | session.revoked | 271 ms | |
| info | seat.added | 308 ms | |
| info | invoice.finalized | 345 ms | |
| info | key.rotated | 382 ms | |
| info | webhook.retried | 419 ms | |
| warn | member.invited | 456 ms | |
| info | export.requested | 13 ms | |
| error | plan.changed | 50 ms | |
| info | session.revoked | 87 ms | |
| info | seat.added | 124 ms | |
| warn | invoice.finalized | 161 ms | |
| info | key.rotated | 198 ms | |
| info | webhook.retried | 235 ms | |
| info | member.invited | 272 ms | |
| info | export.requested | 309 ms | |
| warn | plan.changed | 346 ms | |
| info | session.revoked | 383 ms | |
| info | seat.added | 420 ms | |
| error | invoice.finalized | 457 ms | |
| info | key.rotated | 14 ms | |
| warn | webhook.retried | 51 ms | |
| info | member.invited | 88 ms | |
| info | export.requested | 125 ms | |
| info | plan.changed | 162 ms | |
| info | session.revoked | 199 ms |
Pick a row to open its event.
Loading and empty states
loading keeps the header and column widths in place and swaps the body for skeleton rows, so the table does not resize when the data lands. An empty table tells the two cases apart: no data at all, or no match for the current search, which comes with a button that clears it.
| Last used | ||
|---|---|---|
Overview
A typed data table with sorting, search and row selection, where rows glide into their new order.
This data component is intended for interfaces that need a typed data table with sorting, search and row selection, where rows glide into their new order, analytics dashboards, and reporting views. Its implementation is provided as editable source so the final behavior and styling stay inside your project.
Technologies
Detected package dependencies: motion.
Use cases
- interfaces that need a typed data table with sorting, search and row selection, where rows glide into their new order
- analytics dashboards
- reporting views
Features
- Copy-paste source that remains in your repository
- TypeScript source included with the component
- Tailwind CSS classes editable in the component source
- Animation implemented with Motion
- Responsive Tailwind variants present in the source
- Reduced-motion handling present in the source
Accessibility
- The source includes ARIA attributes or roles; preserve them when customizing the component.
- The source includes keyboard event handling; verify it alongside pointer behavior.
- The source checks reduced-motion preferences or includes motion-reduce styles.
- Test focus order, keyboard operation, labels, contrast, and screen-reader output in the final application context.
Customization
- Edit the Tailwind utility classes in the copied source to match your spacing, color, and typography tokens.
- Use the documented className surface for local layout adjustments, then edit the source for structural changes.
- Adjust Motion transitions in the source and keep the reduced-motion behavior aligned with the final interaction.
Related components
Topic guides
See how this component fits into broader interface patterns, implementation decisions, and working examples.
Data Table FAQ
Is Data Table free to use in commercial projects?
Yes. The public Data Table source is available under the Apache License 2.0, including for commercial use, subject to the LICENSE terms.
How do I install Data Table?
Run npx shadcn@latest add @spectrumui/data-table. The page also exposes the source for manual installation.
Does Data Table require Motion?
Yes. Motion usage was detected in the documented source or registry dependencies for Data Table.
Does Data Table use shadcn/ui or Radix UI?
No shadcn/ui or Radix UI dependency was detected in the documented Data Table source.
Can I use Data Table in Next.js?
The documented React source is used in this Next.js application. Keep its use client directive when copying it into the App Router. Install the detected dependencies and verify any application-specific data or image configuration.
What accessibility checks should I run for Data Table?
The source includes ARIA markup, keyboard handlers, reduced-motion handling. Test the finished interface with keyboard and screen-reader workflows.