Data Table
A typed, sortable, searchable table with row selection, bulk actions and pagination.
npx shadcn@latest add https://sundaeui.dev/r/data-table.json'use client'
import { useMemo, useState } from 'react'
export interface Column<T> {
key: keyof T & string
header: string
/** Custom cell renderer. */
cell?: (row: T) => React.ReactNode
sortable?: boolean
align?: 'left' | 'right'
/** Hide below the sm breakpoint. */
hideOnMobile?: boolean
}
export interface DataTableProps<T extends { id: string }> {
rows: T[]
columns: Column<T>[]
/** Fields matched by the search box. */
searchKeys?: (keyof T & string)[]
pageSize?: number
selectable?: boolean
/** Rendered in the toolbar when rows are selected. */
bulkActions?: (selected: T[], clear: () => void) => React.ReactNode
emptyMessage?: string
caption?: string
}
type Sort<T> = { key: keyof T & string; dir: 'asc' | 'desc' } | null
/** A sortable, searchable, selectable, paginated table with a bulk-action bar. */
export function DataTable<T extends { id: string }>({
rows,
columns,
searchKeys = [],
pageSize = 8,
selectable = true,
bulkActions,
emptyMessage = 'No results match your search.',
caption = 'Table',
}: DataTableProps<T>) {
const [q, setQ] = useState('')
const [sort, setSort] = useState<Sort<T>>(null)
const [page, setPage] = useState(0)
const [selected, setSelected] = useState<Set<string>>(new Set())
const filtered = useMemo(() => {
const needle = q.trim().toLowerCase()
let out = needle ? rows.filter((r) => searchKeys.some((k) => String(r[k] ?? '').toLowerCase().includes(needle))) : rows
if (sort)
out = [...out].sort((a, b) => {
const x = a[sort.key]
const y = b[sort.key]
const c = typeof x === 'number' && typeof y === 'number' ? x - y : String(x).localeCompare(String(y), undefined, { numeric: true })
return sort.dir === 'asc' ? c : -c
})
return out
}, [rows, q, sort, searchKeys])
const pages = Math.max(1, Math.ceil(filtered.length / pageSize))
const current = Math.min(page, pages - 1)
const visible = filtered.slice(current * pageSize, current * pageSize + pageSize)
const allOnPage = visible.length > 0 && visible.every((r) => selected.has(r.id))
const someOnPage = visible.some((r) => selected.has(r.id))
const toggle = (id: string) =>
setSelected((s) => {
const n = new Set(s)
if (n.has(id)) n.delete(id)
else n.add(id)
return n
})
const clear = () => setSelected(new Set())
return (
<div className="w-full overflow-hidden rounded-xl border border-zinc-200 bg-white text-zinc-900 dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-100">
<div className="flex min-h-14 flex-wrap items-center gap-3 border-b border-zinc-200 px-4 py-2.5 dark:border-zinc-800">
{selected.size > 0 ? (
<div className="flex w-full items-center gap-3 motion-safe:animate-[dt-in_200ms_ease-out]">
<span className="text-[13px] font-medium tabular-nums">{selected.size} selected</span>
<div className="ml-auto flex items-center gap-2">
{bulkActions?.(rows.filter((r) => selected.has(r.id)), clear)}
<button type="button" onClick={clear} className="h-8 rounded-md px-2.5 text-[13px] text-zinc-500 hover:bg-zinc-100 hover:text-zinc-900 dark:hover:bg-zinc-900 dark:hover:text-white">
Clear
</button>
</div>
</div>
) : (
<div className="relative w-full max-w-xs">
<svg viewBox="0 0 16 16" className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-zinc-400" aria-hidden>
<circle cx="7" cy="7" r="4.5" fill="none" stroke="currentColor" strokeWidth="1.5" />
<path d="m10.5 10.5 3 3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
<input
type="search"
value={q}
onChange={(e) => {
setQ(e.target.value)
setPage(0)
}}
placeholder="Search…"
aria-label={`Search ${caption.toLowerCase()}`}
className="h-9 w-full rounded-lg border border-zinc-200 bg-transparent pl-8 pr-3 text-[13.5px] outline-none focus:border-zinc-900 focus:ring-4 focus:ring-zinc-900/5 dark:border-zinc-800 dark:focus:border-zinc-300"
/>
</div>
)}
</div>
<div className="overflow-x-auto">
<table className="w-full border-collapse text-left text-[13.5px]">
<caption className="sr-only">{caption}</caption>
<thead>
<tr className="border-b border-zinc-200 text-[12px] text-zinc-500 dark:border-zinc-800">
{selectable && (
<th scope="col" className="w-10 px-4 py-2.5">
<input
type="checkbox"
aria-label="Select all rows on this page"
checked={allOnPage}
ref={(el) => {
if (el) el.indeterminate = !allOnPage && someOnPage
}}
onChange={() =>
setSelected((s) => {
const n = new Set(s)
visible.forEach((r) => (allOnPage ? n.delete(r.id) : n.add(r.id)))
return n
})
}
className="size-4 accent-zinc-900 dark:accent-white"
/>
</th>
)}
{columns.map((c) => {
const active = sort?.key === c.key
return (
<th
key={c.key}
scope="col"
aria-sort={active ? (sort!.dir === 'asc' ? 'ascending' : 'descending') : c.sortable ? 'none' : undefined}
className={`px-4 py-2.5 font-medium ${c.align === 'right' ? 'text-right' : ''} ${c.hideOnMobile ? 'hidden sm:table-cell' : ''}`}
>
{c.sortable ? (
<button
type="button"
onClick={() => setSort(active ? (sort!.dir === 'asc' ? { key: c.key, dir: 'desc' } : null) : { key: c.key, dir: 'asc' })}
className={`inline-flex items-center gap-1 rounded transition-colors hover:text-zinc-900 dark:hover:text-white ${active ? 'text-zinc-900 dark:text-white' : ''}`}
>
{c.header}
<svg viewBox="0 0 10 14" className={`h-3 w-2.5 transition-transform ${active && sort!.dir === 'desc' ? 'rotate-180' : ''}`} aria-hidden>
<path d="M5 2 8.5 6h-7Z" fill="currentColor" opacity={active ? 1 : 0.35} />
<path d="M5 12 1.5 8h7Z" fill="currentColor" opacity={active ? 0 : 0.35} />
</svg>
</button>
) : (
c.header
)}
</th>
)
})}
</tr>
</thead>
<tbody>
{visible.length === 0 ? (
<tr>
<td colSpan={columns.length + (selectable ? 1 : 0)} className="px-4 py-16 text-center text-zinc-500">
{emptyMessage}
{q && (
<button type="button" onClick={() => setQ('')} className="ml-2 font-medium text-zinc-900 underline underline-offset-4 dark:text-white">
Clear search
</button>
)}
</td>
</tr>
) : (
visible.map((r) => {
const on = selected.has(r.id)
return (
<tr key={r.id} aria-selected={selectable ? on : undefined} className={`border-b border-zinc-100 transition-colors last:border-0 dark:border-zinc-900 ${on ? 'bg-zinc-50 dark:bg-zinc-900/60' : 'hover:bg-zinc-50/60 dark:hover:bg-zinc-900/30'}`}>
{selectable && (
<td className="px-4 py-3">
<input type="checkbox" aria-label={`Select row ${r.id}`} checked={on} onChange={() => toggle(r.id)} className="size-4 accent-zinc-900 dark:accent-white" />
</td>
)}
{columns.map((c) => (
<td key={c.key} className={`px-4 py-3 ${c.align === 'right' ? 'text-right tabular-nums' : ''} ${c.hideOnMobile ? 'hidden sm:table-cell' : ''}`}>
{c.cell ? c.cell(r) : String(r[c.key] ?? '')}
</td>
))}
</tr>
)
})
)}
</tbody>
</table>
</div>
<div className="flex items-center justify-between border-t border-zinc-200 px-4 py-2.5 text-[12.5px] text-zinc-500 dark:border-zinc-800">
<span className="tabular-nums">
{filtered.length === 0 ? '0' : `${current * pageSize + 1}–${Math.min(filtered.length, (current + 1) * pageSize)}`} of {filtered.length}
</span>
<nav aria-label="Table pagination" className="flex items-center gap-1">
<button type="button" disabled={current === 0} onClick={() => setPage(current - 1)} className="h-8 rounded-md border border-zinc-200 px-2.5 disabled:opacity-40 dark:border-zinc-800">
Previous
</button>
<span className="px-2 tabular-nums">
{current + 1} / {pages}
</span>
<button type="button" disabled={current >= pages - 1} onClick={() => setPage(current + 1)} className="h-8 rounded-md border border-zinc-200 px-2.5 disabled:opacity-40 dark:border-zinc-800">
Next
</button>
</nav>
</div>
<style>{`@keyframes dt-in{from{opacity:0;transform:translateY(-3px)}}`}</style>
</div>
)
}Overview
The table most products need on day one. Columns are typed against your row shape, cells can render anything, and any column can be sortable: click once for ascending, twice for descending, and a third time to clear. Search filters on the fields you choose and resets to the first page.
Selecting rows swaps the search toolbar for a bulk-action bar, with your own actions such as mark paid or delete. The header checkbox selects the current page and shows an indeterminate state when only some rows are selected. An empty search shows a clear-search action instead of a blank table.
Use it for invoices, users, orders and logs, up to a few thousand rows held in the client. For server-paginated or very large datasets, keep the visual layer and move filtering and sorting into your query.
When to use
- Dashboards and reports where the shape of the data matters.
When not to use
- Exact lookups: give people a table or export too.
Installation
pnpm dlx shadcn@latest add https://sundaeui.dev/r/data-table.jsonnpx shadcn@latest add https://sundaeui.dev/r/data-table.jsonyarn dlx shadcn@latest add https://sundaeui.dev/r/data-table.jsonbunx --bun shadcn@latest add https://sundaeui.dev/r/data-table.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy data-table.tsx into components/sundae/.
- Make sure Tailwind scans that folder (v4 detects it automatically; v3 needs it in `content`).
- Import the component and use it as shown below.
Usage
import { DataTable } from '@/components/sundae/data-table'
<DataTable
caption="Invoices"
rows={invoices} // { id: string, ... }[]
searchKeys={['id', 'customer']}
columns={[
{ key: 'id', header: 'Invoice', sortable: true },
{ key: 'customer', header: 'Customer', sortable: true },
{ key: 'amount', header: 'Amount', sortable: true, align: 'right', cell: (r) => format(r.amount) },
]}
bulkActions={(selected, clear) => <button onClick={() => archive(selected).then(clear)}>Archive</button>}
/>Props
| Prop | Type | Default |
|---|---|---|
rows*Data rows. | T[] (with id: string) | — |
columns*{ key, header, cell?, sortable?, align?, hideOnMobile? }. | Column<T>[] | — |
searchKeysFields the search box matches. | (keyof T)[] | — |
pageSizeRows per page. | number | 8 |
selectableAdds row checkboxes. | boolean | true |
bulkActionsToolbar content shown while rows are selected. | (selected, clear) => ReactNode | — |
emptyMessageShown when nothing matches. | string | — |
captionAccessible table caption. | string | 'Table' |
Accessibility
- A real <table> with a caption, column scopes and aria-sort on sortable headers.
- Sort controls are buttons inside the headers. Selection uses labelled checkboxes, with an indeterminate state on the header.
- Pagination is a labelled navigation landmark.
Customization
- Mark columns hideOnMobile to keep small screens readable. The table also scrolls horizontally.
- Recolour by swapping the zinc classes.
FAQ
- Is Data Table free?
- Yes. Data Table is free for personal and commercial projects, with full source on this page.
- How do I install Data Table?
- Run npx shadcn@latest add https://sundaeui.dev/r/data-table.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Data Table support keyboard and screen readers?
- A real <table> with a caption, column scopes and aria-sort on sortable headers. Sort controls are buttons inside the headers. Selection uses labelled checkboxes, with an indeterminate state on the header.
- Does Data Table respect reduced motion?
- Yes. With prefers-reduced-motion set, decorative motion is removed and state changes are instant.
More like this
All data →Pulse KPI
Data · Understated- Pro
Kanban Board
Data · Understated Usage Meters
Data · Understated- Pro
Team Members
Components · Understated Stacked Bars
Data · Refined- Pro
Heatmap Grid
Data · Understated