Data Table

A typed data table with sorting, search and row selection, where rows glide into their new order.

@spectrumui/data-table
Payments from the last two weeks, with status, method, date and amount.
Actions
Marta Halapinmarta@northsail.co
paid$1,840
Devansh Raodevansh@quillbase.io
pending$420
Ines Almeidaines@fold.studio
paid$96
Tobias Wernertobias@heliolab.de
failed$2,400
Amara Okonjoamara@brightpath.ng
paid$780
Rafael Costarafael@medela.com.br
refunded$96
Total$17,408
14 rows

Installation

Login to view commandCreate a free account to access the install command

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

PropTypeDefaultDescription
*datareadonly T[]-Rows to render
*columnsDataTableColumn<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
captionstring-Sentence describing the table for screen readers; never painted
titleReactNode-Heading at the start of the toolbar
toolbarReactNode-Controls parked at the end of the toolbar
searchablebooleanfalseAdds 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
defaultSortDataTableSort | nullnullSort the table starts on, when uncontrolled
sortDataTableSort | null-Controlled sort; pair with onSortChange
onSortChange(sort: DataTableSort | null) => void-Fires on every header click, including the one that clears the sort
selectablebooleanfalseAdds the checkbox column and the header select-all
selectedIdsstring[]-Controlled selection; pair with onSelectedChange
defaultSelectedIdsstring[]-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
pageSizenumber-Rows per page. Omit to render every row
loadingbooleanfalseSwaps the body for skeleton rows and marks the table aria-busy
skeletonRowsnumber5How many skeleton rows to show while loading
emptyStateReactNode-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
keyboardNavigationbooleantrueArrow-key cursor, Shift range selection and the ⌘A / ⌘C / Space shortcuts
clipboardbooleantrueCopy button in the bulk bar, and ⌘C, writing the selection as TSV
totalsstring[]-Column ids to sum in a footer that rolls to its new value as you filter
resizableColumnsbooleanfalseDrag a header’s trailing edge to resize it; the splitter also takes arrow keys
pinFirstColumnbooleanfalseKeeps the leading cells in place, with a shadow, while the table scrolls sideways
stickyHeaderbooleanfalsePins the header and lifts it with a shadow once the body scrolls
maxHeightnumber | string-Caps the scroll area, e.g. 360 or "60vh". Pairs with stickyHeader
animatebooleantrueSet false to drop the reorder, disclosure and selection motion
classNamestring-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].

PropTypeDefaultDescription
*idstring-Stable key, and the property read off the row when value is absent
*headerReactNode-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
sortablebooleanfalseTurns the header into a three-step sort button
numericbooleanfalseEnd-aligns the column and switches it to tabular figures
align"start" | "center" | "end""start"Overrides the alignment numeric would otherwise pick
widthnumber | 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
classNamestring-Classes merged onto every body cell in the column
headerClassNamestring-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.

Open pipeline with stage, owner, win probability and value.
HavenpointlostFreya Lindgren$138,000
CopperlineproposalLeila Haddad$129,600
NordformnegotiationZara Osei$121,200
TidepoolwonAiko Watanabe$112,800
PaperkitelostMateo Alvarez$104,400
ArcfieldproposalFreya Lindgren$96,000
LoomstacknegotiationLeila Haddad$87,600
CarbonlinewonZara Osei$79,200
ZoninglostAiko Watanabe$70,800
Kirin WorksproposalMateo Alvarez$62,400
Medela BrasilnegotiationFreya Lindgren$54,000
BrightpathwonLeila Haddad$45,600
HeliolablostZara Osei$37,200
Fold StudioproposalAiko Watanabe$28,800
QuillbasenegotiationMateo Alvarez$20,400
Northsail GroupwonFreya Lindgren$12,000
Total$1,200,000
16 rows
  • ↑ ↓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.

Variant
Density
Service traffic for the last hour, by region.
api-gatewayiad11.3M92 ms
search-indexsfo1268K148 ms
webhook-relaysin177K221 ms
billing-workerfra141K310 ms
maileriad19.8K64 ms
5 rows

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.

Audit events for this workspace, newest first.
infoseat.added12 ms
infoinvoice.finalized49 ms
warnkey.rotated86 ms
errorwebhook.retried123 ms
infomember.invited160 ms
infoexport.requested197 ms
infoplan.changed234 ms
warnsession.revoked271 ms
infoseat.added308 ms
infoinvoice.finalized345 ms
infokey.rotated382 ms
infowebhook.retried419 ms
warnmember.invited456 ms
infoexport.requested13 ms
errorplan.changed50 ms
infosession.revoked87 ms
infoseat.added124 ms
warninvoice.finalized161 ms
infokey.rotated198 ms
infowebhook.retried235 ms
infomember.invited272 ms
infoexport.requested309 ms
warnplan.changed346 ms
infosession.revoked383 ms
infoseat.added420 ms
errorinvoice.finalized457 ms
infokey.rotated14 ms
warnwebhook.retried51 ms
infomember.invited88 ms
infoexport.requested125 ms
infoplan.changed162 ms
infosession.revoked199 ms
32 rows

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.

API keys for this workspace.
Last used
Loading rows

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

React
TypeScript
Tailwind CSS
Motion

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.

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.

Work with me