Zubeyr··ComponentstablesfiltersData Table
An index table with view tabs, search, filter chips, a sort menu, sortable headers, row selection with bulk actions, loading rows and an empty state.
Live demo ↗Installation
Run the shadcn CLI in a project that already has components.json. Using the @tomei tab? Register the namespace once first.
pnpm dlx shadcn@latest add https://tomei.zubeyr.dev/ui/r/data-table.json- →Pulls in
popover. - →Files land in
components/dashboard.
Usage
'use client'
import { useState } from 'react'
import { Badge } from '@/registry/dashboard/components/badge/badge'
import { DataTable, type Column, type Filter, type View } from '@/components/dashboard/data-table'
type Order = {
id: string
placed: number
date: string
customer: string
total: number
payment: 'Paid' | 'Payment pending' | 'Refunded'
fulfillment: 'Unfulfilled' | 'Fulfilled' | 'On hold'
items: number
}
const names = ['Ava Brooks', 'Leo Turner', 'Mia Patel', 'Noah Kim', 'Zoe Carter', 'Eli Morgan']
const payments: Order['payment'][] = ['Paid', 'Paid', 'Payment pending', 'Paid', 'Refunded']
const fulfillments: Order['fulfillment'][] = ['Unfulfilled', 'Fulfilled', 'On hold', 'Unfulfilled']
const orders: Order[] = Array.from({ length: 12 }, (_, i) => ({
id: `#${2050 - i}`,
placed: 12 - i,
date:
i < 4
? `Today at ${9 - i * 2}:${String(55 - i * 7).padStart(2, '0')} a.m.`
: `${i - 3} days ago`,
customer: names[i % names.length],
total: [98.99, 396.84, 54.2, 1210.5][i % 4],
payment: payments[i % payments.length],
fulfillment: fulfillments[i % fulfillments.length],
items: (i % 3) + 1,
}))
const paymentTone = {
Paid: 'neutral',
'Payment pending': 'attention',
Refunded: 'critical',
} as const
const fulfillmentTone = {
Unfulfilled: 'warning',
Fulfilled: 'success',
'On hold': 'warning',
} as const
const currency = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' })
const columns: Column<Order>[] = [
{
key: 'order',
header: 'Order',
sortValue: (o) => o.placed,
cell: (o) => <span className="font-semibold">{o.id}</span>,
},
{ key: 'date', header: 'Date', sortValue: (o) => o.placed, cell: (o) => o.date },
{ key: 'customer', header: 'Customer', sortValue: (o) => o.customer, cell: (o) => o.customer },
{
key: 'total',
header: 'Total',
align: 'right',
sortValue: (o) => o.total,
cell: (o) => currency.format(o.total),
},
{
key: 'payment',
header: 'Payment status',
cell: (o) => (
<Badge tone={paymentTone[o.payment]} indicator={o.payment === 'Paid' ? 'dot' : 'ring'}>
{o.payment}
</Badge>
),
},
{
key: 'fulfillment',
header: 'Fulfillment status',
cell: (o) => (
<Badge
tone={fulfillmentTone[o.fulfillment]}
indicator={o.fulfillment === 'Fulfilled' ? 'dot' : 'ring'}
>
{o.fulfillment}
</Badge>
),
},
{
key: 'items',
header: 'Items',
sortValue: (o) => o.items,
cell: (o) => `${o.items} ${o.items === 1 ? 'item' : 'items'}`,
},
]
const views: View<Order>[] = [
{ label: 'Unfulfilled', filter: (o) => o.fulfillment === 'Unfulfilled' },
{ label: 'Unpaid', filter: (o) => o.payment === 'Payment pending' },
{ label: 'On hold', filter: (o) => o.fulfillment === 'On hold' },
]
const filters: Filter<Order>[] = [
{
key: 'payment',
label: 'Payment status',
options: ['Paid', 'Payment pending', 'Refunded'],
value: (o) => o.payment,
},
{
key: 'fulfillment',
label: 'Fulfillment status',
options: ['Unfulfilled', 'Fulfilled', 'On hold'],
value: (o) => o.fulfillment,
},
]
export default function Demo() {
const [loading, setLoading] = useState(false)
const refresh = () =>
new Promise<void>((resolve) => {
setLoading(true)
setTimeout(() => {
setLoading(false)
resolve()
}, 900)
})
return (
<div className="w-full max-w-5xl">
<DataTable
rows={orders}
columns={columns}
getRowId={(o) => o.id}
views={views}
filters={filters}
searchText={(o) => `${o.id} ${o.customer}`}
searchPlaceholder="Search orders or customers"
selectable
pageSize={6}
onRefresh={refresh}
loading={loading}
bulkActions={() => (
<>
<button type="button" className="text-foreground hover:underline">
Mark as fulfilled
</button>
<button type="button" className="text-foreground hover:underline">
Print labels
</button>
</>
)}
/>
</div>
)
}Source code
'use client'
import { useEffect, useMemo, useRef, useState } from 'react'
import { cn } from '@/lib/utils'
import { Popover } from '@/registry/dashboard/components/popover/popover'
export type Column<T> = {
key: string
header: React.ReactNode
cell: (row: T) => React.ReactNode
sortValue?: (row: T) => string | number
sortLabel?: string
align?: 'left' | 'right'
className?: string
}
export type View<T> = { label: string; filter: (row: T) => boolean }
export type Filter<T> = {
key: string
label: string
options: string[]
value: (row: T) => string
}
type Sort = { key: string; direction: 'asc' | 'desc' }
type DataTableProps<T> = {
rows: T[]
columns: Column<T>[]
getRowId: (row: T) => string
views?: View<T>[]
filters?: Filter<T>[]
searchText?: (row: T) => string
searchPlaceholder?: string
defaultSort?: Sort
selectable?: boolean
bulkActions?: (selected: string[]) => React.ReactNode
pageSize?: number
pageSizeOptions?: number[]
onRefresh?: () => void | Promise<void>
loading?: boolean
skeletonRows?: number
empty?: React.ReactNode
className?: string
}
const iconButton =
'bg-card text-foreground hover:bg-muted inline-flex size-8 items-center justify-center rounded-lg shadow-(--shadow-button) [&_svg]:size-4'
function Checkbox({
checked,
indeterminate = false,
onChange,
label,
}: {
checked: boolean
indeterminate?: boolean
onChange: (checked: boolean) => void
label: string
}) {
const ref = useRef<HTMLInputElement>(null)
useEffect(() => {
if (ref.current) ref.current.indeterminate = indeterminate
}, [indeterminate])
return (
<input
ref={ref}
type="checkbox"
aria-label={label}
checked={checked}
onChange={(e) => onChange(e.target.checked)}
className="block size-4 cursor-pointer accent-primary dark:scheme-dark"
/>
)
}
export function DataTable<T>({
rows,
columns,
getRowId,
views = [],
filters = [],
searchText,
searchPlaceholder = 'Search',
defaultSort,
selectable = false,
bulkActions,
pageSize,
pageSizeOptions = [10, 25, 50],
onRefresh,
loading = false,
skeletonRows = 4,
empty = 'No results',
className,
}: DataTableProps<T>) {
const [view, setView] = useState(-1)
const [searching, setSearching] = useState(false)
const [query, setQuery] = useState('')
const [applied, setApplied] = useState<Record<string, string[]>>({})
const [sort, setSort] = useState<Sort | undefined>(defaultSort)
const [selected, setSelected] = useState<Set<string>>(new Set())
const [page, setPage] = useState(0)
const [limit, setLimit] = useState(pageSize ?? 0)
const [refreshing, setRefreshing] = useState(false)
const sortable = columns.filter((c) => c.sortValue)
const visible = useMemo(() => {
const q = query.trim().toLowerCase()
const text = searchText ?? ((row: T) => Object.values(row as object).join(' '))
const result = rows.filter(
(row) =>
(view < 0 || views[view].filter(row)) &&
(!q || text(row).toLowerCase().includes(q)) &&
filters.every((f) => !applied[f.key]?.length || applied[f.key].includes(f.value(row))),
)
const column = sort && columns.find((c) => c.key === sort.key)
if (!column?.sortValue || !sort) return result
const value = column.sortValue
return [...result].sort((a, b) => {
const [x, y] = [value(a), value(b)]
const order = x < y ? -1 : x > y ? 1 : 0
return sort.direction === 'asc' ? order : -order
})
}, [rows, columns, views, view, filters, applied, query, searchText, sort])
const pageCount = limit ? Math.max(1, Math.ceil(visible.length / limit)) : 1
const current = Math.min(page, pageCount - 1)
const pageRows = limit ? visible.slice(current * limit, (current + 1) * limit) : visible
const span = columns.length + (selectable ? 1 : 0)
const visibleIds = pageRows.map(getRowId)
const selectedVisible = visibleIds.filter((id) => selected.has(id))
const allSelected = visibleIds.length > 0 && selectedVisible.length === visibleIds.length
const toggleAll = (checked: boolean) => setSelected(checked ? new Set(visibleIds) : new Set())
const toggleRow = (id: string, checked: boolean) => {
const next = new Set(selected)
if (checked) next.add(id)
else next.delete(id)
setSelected(next)
}
const toggleFilter = (key: string, option: string) => {
const active = applied[key] ?? []
setPage(0)
setApplied({
...applied,
[key]: active.includes(option) ? active.filter((o) => o !== option) : [...active, option],
})
}
const refresh = async () => {
if (!onRefresh) return
setRefreshing(true)
try {
await onRefresh()
} finally {
setRefreshing(false)
}
}
const sortBy = (key: string) =>
setSort(
sort?.key === key
? { key, direction: sort.direction === 'asc' ? 'desc' : 'asc' }
: { key, direction: 'asc' },
)
const stopSearching = () => {
setSearching(false)
setQuery('')
setApplied({})
setPage(0)
}
return (
<div
className={cn('rounded-xl bg-card text-card-foreground shadow-(--shadow-card)', className)}
>
<div className="flex min-h-12 items-center gap-2 border-b px-2 py-1.5">
{searching ? (
<div className="flex flex-1 items-center gap-2">
<label className="flex h-8 flex-1 items-center gap-2 rounded-lg border border-input bg-card px-2.5 focus-within:border-ring">
<SearchIcon />
<input
autoFocus
value={query}
onChange={(e) => {
setQuery(e.target.value)
setPage(0)
}}
placeholder={searchPlaceholder}
aria-label={searchPlaceholder}
className="h-full min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
/>
</label>
<button
type="button"
onClick={stopSearching}
className="h-8 rounded-lg px-3 text-sm font-medium hover:bg-muted"
>
Cancel
</button>
</div>
) : (
<div role="tablist" className="flex flex-1 gap-0.5 overflow-x-auto">
{[{ label: 'All' }, ...views].map((v, i) => (
<button
key={v.label}
type="button"
role="tab"
aria-selected={view === i - 1}
onClick={() => {
setView(i - 1)
setPage(0)
setSelected(new Set())
}}
className={cn(
'h-8 shrink-0 rounded-lg px-3 text-sm font-medium whitespace-nowrap transition-colors',
view === i - 1
? 'bg-muted text-foreground'
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground',
)}
>
{v.label}
</button>
))}
</div>
)}
{!searching && (
<button
type="button"
aria-label="Search and filter"
onClick={() => setSearching(true)}
className={cn(iconButton, 'w-12 gap-0.5')}
>
<SearchIcon />
<FilterIcon />
</button>
)}
{onRefresh && (
<button
type="button"
aria-label="Refresh"
disabled={refreshing}
onClick={refresh}
className={cn(iconButton, 'disabled:opacity-60')}
>
<RefreshIcon spinning={refreshing} />
</button>
)}
{sortable.length > 0 && (
<Popover
align="end"
trigger={({ toggle, open }) => (
<button
type="button"
aria-label="Sort"
aria-expanded={open}
onClick={toggle}
className={iconButton}
>
<SortIcon />
</button>
)}
>
<p className="px-2 pt-1 pb-2 text-xs font-medium text-muted-foreground">Sort by</p>
{sortable.map((c) => (
<label
key={c.key}
className="flex h-8 cursor-pointer items-center gap-2 rounded-lg px-2 text-sm hover:bg-muted"
>
<input
type="radio"
name="sort"
checked={sort?.key === c.key}
onChange={() => setSort({ key: c.key, direction: sort?.direction ?? 'asc' })}
className="accent-primary dark:scheme-dark"
/>
{c.sortLabel ?? c.header}
</label>
))}
<div className="mt-1.5 grid grid-cols-2 gap-1 border-t pt-1.5">
{(['asc', 'desc'] as const).map((direction) => (
<button
key={direction}
type="button"
disabled={!sort}
onClick={() => sort && setSort({ ...sort, direction })}
className={cn(
'h-8 rounded-lg text-sm hover:bg-muted disabled:opacity-50',
sort?.direction === direction && 'bg-muted font-medium',
)}
>
{direction === 'asc' ? '↑ Ascending' : '↓ Descending'}
</button>
))}
</div>
</Popover>
)}
</div>
{searching && filters.length > 0 && (
<div className="flex flex-wrap items-center gap-1.5 border-b px-3 py-2">
{filters.map((f) => {
const active = applied[f.key] ?? []
return (
<Popover
key={f.key}
trigger={({ toggle, open }) => (
<span
className={cn(
'inline-flex h-7 items-center rounded-full border text-xs font-medium',
active.length ? 'border-transparent bg-muted' : 'border-dashed',
)}
>
<button
type="button"
aria-expanded={open}
onClick={toggle}
className="flex h-full items-center gap-1 pr-1.5 pl-3"
>
{f.label}
{active.length > 0 && (
<span className="text-muted-foreground">: {active.join(', ')}</span>
)}
<span aria-hidden className="text-muted-foreground">
▾
</span>
</button>
{active.length > 0 && (
<button
type="button"
aria-label={`Clear ${f.label}`}
onClick={() => setApplied({ ...applied, [f.key]: [] })}
className="h-full pr-2.5 text-muted-foreground hover:text-foreground"
>
✕
</button>
)}
</span>
)}
>
{f.options.map((option) => (
<label
key={option}
className="flex h-8 cursor-pointer items-center gap-2 rounded-lg px-2 text-sm hover:bg-muted"
>
<input
type="checkbox"
checked={active.includes(option)}
onChange={() => toggleFilter(f.key, option)}
className="size-4 accent-primary dark:scheme-dark"
/>
{option}
</label>
))}
</Popover>
)
})}
{Object.values(applied).some((v) => v.length) && (
<button
type="button"
onClick={() => setApplied({})}
className="px-2 text-xs font-medium text-muted-foreground hover:text-foreground"
>
Clear all
</button>
)}
</div>
)}
<div className="overflow-x-auto rounded-b-xl">
<table className="w-full border-collapse text-sm">
<thead className="bg-muted/50 text-muted-foreground">
<tr className="h-10 border-b">
{selectable && (
<th className="w-10 pl-3 text-left align-middle">
<Checkbox
label="Select all rows"
checked={allSelected}
indeterminate={selectedVisible.length > 0 && !allSelected}
onChange={toggleAll}
/>
</th>
)}
{selectable && selectedVisible.length > 0 ? (
<th colSpan={columns.length} className="px-3 text-left font-medium">
<div className="flex items-center gap-3">
<span className="text-foreground">{selectedVisible.length} selected</span>
{bulkActions?.(selectedVisible)}
</div>
</th>
) : (
columns.map((c) => (
<th
key={c.key}
aria-sort={
sort?.key === c.key
? sort.direction === 'asc'
? 'ascending'
: 'descending'
: undefined
}
className={cn(
'px-3 font-medium whitespace-nowrap',
c.align === 'right' ? 'text-right' : 'text-left',
c.className,
)}
>
{c.sortValue ? (
<button
type="button"
onClick={() => sortBy(c.key)}
className={cn(
'inline-flex items-center gap-1 hover:text-foreground',
sort?.key === c.key && 'text-foreground',
)}
>
{c.header}
<span aria-hidden className="text-[10px]">
{sort?.key === c.key ? (sort.direction === 'asc' ? '▲' : '▼') : '⇅'}
</span>
</button>
) : (
c.header
)}
</th>
))
)}
</tr>
</thead>
<tbody>
{loading
? Array.from({ length: limit || skeletonRows }, (_, i) => (
<tr key={i} className="h-11 border-b last:border-0">
{selectable && (
<td className="w-10 pl-3 align-middle">
<span className="block size-4 rounded bg-muted" />
</td>
)}
{columns.map((c) => (
<td key={c.key} className="px-3">
<span
className={cn(
'block h-3 animate-pulse rounded-full bg-muted',
c.align === 'right' ? 'ml-auto w-14' : 'w-24',
)}
/>
</td>
))}
</tr>
))
: pageRows.map((row) => {
const id = getRowId(row)
const isSelected = selected.has(id)
return (
<tr
key={id}
data-selected={isSelected}
className="h-11 border-b transition-colors last:border-0 hover:bg-muted/50 data-[selected=true]:bg-muted"
>
{selectable && (
<td className="w-10 pl-3 align-middle">
<Checkbox
label={`Select row ${id}`}
checked={isSelected}
onChange={(checked) => toggleRow(id, checked)}
/>
</td>
)}
{columns.map((c) => (
<td
key={c.key}
className={cn(
'px-3 whitespace-nowrap',
c.align === 'right' && 'text-right tabular-nums',
c.className,
)}
>
{c.cell(row)}
</td>
))}
</tr>
)
})}
{!loading && visible.length === 0 && (
<tr>
<td colSpan={span} className="h-32 text-center text-sm text-muted-foreground">
{empty}
</td>
</tr>
)}
</tbody>
</table>
</div>
{limit > 0 && (
<div className="flex flex-wrap items-center justify-between gap-3 border-t px-3 py-2 text-sm text-muted-foreground">
<div className="flex items-center gap-4">
<span className="flex items-center gap-2">
Rows
<Popover
side="top"
className="min-w-24"
trigger={({ open, toggle }) => (
<button
type="button"
aria-haspopup="listbox"
aria-expanded={open}
aria-label={`Rows per page: ${limit}`}
onClick={toggle}
className="inline-flex h-8 items-center gap-1.5 rounded-lg bg-card px-2.5 font-medium text-foreground tabular-nums shadow-(--shadow-button) hover:bg-muted"
>
{limit}
<span aria-hidden className="text-[10px] text-muted-foreground">
▾
</span>
</button>
)}
>
{(close) => (
<ul role="listbox" aria-label="Rows per page">
{[...new Set([...pageSizeOptions, pageSize ?? limit])]
.sort((x, y) => x - y)
.map((n) => (
<li key={n}>
<button
type="button"
role="option"
aria-selected={n === limit}
onClick={() => {
setLimit(n)
setPage(0)
close()
}}
className="flex h-8 w-full items-center justify-between gap-3 rounded-lg px-2 text-sm text-foreground tabular-nums hover:bg-muted"
>
{n}
{n === limit && <span aria-hidden>✓</span>}
</button>
</li>
))}
</ul>
)}
</Popover>
</span>
{!loading && visible.length > 0 && (
<span className="tabular-nums">
{current * limit + 1}–{Math.min((current + 1) * limit, visible.length)} of{' '}
{visible.length}
</span>
)}
</div>
<div className="flex gap-1">
{(
[
['first', 'First page', 0, current === 0],
['left', 'Previous page', current - 1, current === 0],
['right', 'Next page', current + 1, current >= pageCount - 1],
['last', 'Last page', pageCount - 1, current >= pageCount - 1],
] as const
).map(([direction, label, target, disabled]) => (
<button
key={direction}
type="button"
aria-label={label}
disabled={disabled || loading}
onClick={() => setPage(target)}
className={cn(iconButton, 'disabled:opacity-40')}
>
<ChevronIcon direction={direction} />
</button>
))}
</div>
</div>
)}
</div>
)
}
function RefreshIcon({ spinning }: { spinning: boolean }) {
return (
<svg
viewBox="0 0 16 16"
fill="none"
aria-hidden
className={cn('size-4', spinning && 'animate-spin')}
>
<path
d="M13.5 8a5.5 5.5 0 1 1-1.6-3.9M13.5 2.5v3h-3"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}
const chevrons = {
first: 'M12 3 7 8l5 5M7.5 3 2.5 8l5 5',
left: 'M10 3 5 8l5 5',
right: 'm6 3 5 5-5 5',
last: 'm4 3 5 5-5 5m4.5-10 5 5-5 5',
}
function ChevronIcon({ direction }: { direction: keyof typeof chevrons }) {
return (
<svg viewBox="0 0 16 16" fill="none" aria-hidden className="size-4">
<path
d={chevrons[direction]}
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}
function SearchIcon() {
return (
<svg
viewBox="0 0 16 16"
fill="none"
aria-hidden
className="size-4 shrink-0 text-muted-foreground"
>
<circle cx="7" cy="7" r="4.5" stroke="currentColor" strokeWidth="1.5" />
<path d="m10.5 10.5 3 3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
)
}
function FilterIcon() {
return (
<svg viewBox="0 0 16 16" fill="none" aria-hidden className="size-4">
<path
d="M2.5 4.5h11M4.5 8h7M6.5 11.5h3"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
)
}
function SortIcon() {
return (
<svg viewBox="0 0 16 16" fill="none" aria-hidden className="size-4">
<path
d="M5 13V3M5 3 2.5 5.5M5 3l2.5 2.5M11 3v10m0 0 2.5-2.5M11 13l-2.5-2.5"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}