TanStack Table wrapper with toolbar, faceted filters, pagination, column visibility and resizing.
Installation
pnpm dlx shadcn@latest add https://rdf.dev.360online.com/r/data-table.jsonInstalls to @/components/ui/data-table.
Manual installation
Install the dependencies:
pnpm add @tanstack/react-table@^8.21.3 lucide-react@^1.48.0Add the components it builds on: badge, button, command, dropdown-menu, input, pagination, popover, separator, table, tooltip.
Copy the source into your project:
'use client'
interface Creator {
id: string
name: string
email?: string
}
interface CreatorNameCellProps {
creator?: Creator | null
}
export function CreatorNameCell({ creator }: CreatorNameCellProps) {
return <span>{creator?.name || '-'}</span>
}import type { Column, SortDirection } from '@tanstack/react-table'
import { ArrowDownIcon, ArrowUpIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
interface DataTableColumnHeaderProps<TData, TValue> extends React.HTMLAttributes<HTMLDivElement> {
column: Column<TData, TValue>
title: string
}
function SortIcon({ direction }: Readonly<{ direction: false | SortDirection }>) {
if (direction === 'desc') {
return <ArrowDownIcon className="ml-1 size-4 shrink-0" aria-hidden="true" focusable="false" />
}
if (direction === 'asc') {
return <ArrowUpIcon className="ml-1 size-4 shrink-0" aria-hidden="true" focusable="false" />
}
return null
}
export function DataTableColumnHeader<TData, TValue>({
column,
title,
className,
}: DataTableColumnHeaderProps<TData, TValue>) {
if (!column.getCanSort()) {
return (
<div className={cn('flex text-sm font-medium items-center text-left min-w-0', className)}>
<span className="truncate">{title}</span>
</div>
)
}
function handleSort() {
if (column.getIsSorted() === false) {
column.toggleSorting(false) // Set to asc
} else if (column.getIsSorted() === 'asc') {
column.toggleSorting(true) // Set to desc
} else {
column.clearSorting() // Clear sorting
}
}
const sortDirection = column.getIsSorted()
const sortLabels: Record<string, string> = { asc: 'sorted ascending', desc: 'sorted descending' }
const sortLabel = sortLabels[sortDirection as string] ?? 'not sorted'
return (
<button
type="button"
className={cn(
'flex w-full min-w-0 cursor-pointer items-center rounded-sm text-sm font-medium outline-none hover:text-foreground/80 focus-visible:ring-3 focus-visible:ring-ring',
className,
)}
onClick={handleSort}
aria-label={`${title}, ${sortLabel}`}
data-state={sortDirection ? 'sorted' : 'unsorted'}
>
<span className="truncate">{title}</span>
{/* Only reserve icon space when actually sorted — an always-on placeholder squeezes narrow
columns (e.g. Version) and forces the header to truncate unnecessarily. */}
<SortIcon direction={sortDirection} />
</button>
)
}import type { Column } from '@tanstack/react-table'
import { CheckIcon, CirclePlusIcon } from 'lucide-react'
import type * as React from 'react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from '@/components/ui/command'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Separator } from '@/components/ui/separator'
import { cn } from '@/lib/utils'
interface DataTableFacetedFilterProps<TData, TValue> {
column?: Column<TData, TValue>
title?: string
options: {
label: string
value: string
icon?: React.ComponentType<{ className?: string }>
}[]
}
export function DataTableFacetedFilter<TData, TValue>({
column,
title,
options,
}: DataTableFacetedFilterProps<TData, TValue>) {
const facets = column?.getFacetedUniqueValues()
const selectedValues = new Set(column?.getFilterValue() as string[])
return (
<Popover>
<PopoverTrigger render={<Button variant="outline" className="border-dashed" />}>
<CirclePlusIcon data-icon="inline-start" />
{title}
{selectedValues?.size > 0 && (
<>
<Separator orientation="vertical" className="mx-0.5 h-4" />
<Badge className="rounded-sm px-1 font-normal lg:hidden">{selectedValues.size}</Badge>
<div className="hidden gap-1 lg:flex">
{selectedValues.size > 2 ? (
<Badge className="rounded-sm px-1 font-normal">{selectedValues.size} selected</Badge>
) : (
options
.filter((option) => selectedValues.has(option.value))
.map((option) => (
<Badge key={option.value} className="rounded-sm px-1 font-normal">
{option.label}
</Badge>
))
)}
</div>
</>
)}
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0" align="start">
<Command>
<CommandInput placeholder={title} />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup>
{options.map((option) => {
const isSelected = selectedValues.has(option.value)
return (
<CommandItem
key={option.value}
onSelect={() => {
if (isSelected) {
selectedValues.delete(option.value)
} else {
selectedValues.add(option.value)
}
const filterValues = Array.from(selectedValues)
column?.setFilterValue(filterValues.length ? filterValues : undefined)
}}
>
<div
className={cn(
'flex size-4 items-center justify-center rounded-[4px] border border-primary',
isSelected ? 'bg-primary text-primary-foreground' : 'opacity-50 [&_svg]:invisible',
)}
>
<CheckIcon className="size-3.5 text-current" />
</div>
{option.icon && <option.icon className="size-4 text-muted-foreground" />}
<span>{option.label}</span>
{facets?.get(option.value) && (
<span className="ml-auto flex size-4 items-center justify-center font-mono text-xs">
{facets.get(option.value)}
</span>
)}
</CommandItem>
)
})}
</CommandGroup>
{selectedValues.size > 0 && (
<>
<CommandSeparator />
<CommandGroup>
<CommandItem onSelect={() => column?.setFilterValue(undefined)} className="justify-center text-center">
Clear filters
</CommandItem>
</CommandGroup>
</>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}'use client'
import type { Table } from '@tanstack/react-table'
import * as React from 'react'
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from '@/components/ui/pagination'
// Pagination links are anchors, which have no `disabled` attribute.
const DISABLED_LINK_PROPS = { 'aria-disabled': true, tabIndex: -1, className: 'pointer-events-none opacity-50' }
function previousLinkProps<TData>(table: Table<TData>) {
return table.getCanPreviousPage() ? {} : DISABLED_LINK_PROPS
}
function nextLinkProps<TData>(table: Table<TData>) {
return table.getCanNextPage() ? {} : DISABLED_LINK_PROPS
}
interface DataTablePaginationProps<TData> {
table: Table<TData>
onPaginationChange?: (values: { pageIndex: number; pageSize: number }) => void
}
export function DataTablePagination<TData>({ table, onPaginationChange }: DataTablePaginationProps<TData>) {
const handlePageChange = React.useCallback(
(newPageIndex: number) => {
onPaginationChange?.({
pageIndex: newPageIndex,
pageSize: table.getState().pagination.pageSize,
})
},
[onPaginationChange, table],
)
// Read current page from the table's controlled state
const currentPageIndex = table.getState().pagination.pageIndex ?? 0
const getPageNumbers = () => {
const totalPages = table.getPageCount()
const pages: (number | string)[] = []
if (totalPages <= 7) {
return Array.from({ length: totalPages }, (_, i) => i)
}
pages.push(0)
const middleStart = Math.max(1, Math.min(currentPageIndex - 1, totalPages - 5))
const middleEnd = Math.min(middleStart + 3, totalPages - 2)
if (middleStart > 1) pages.push('ellipsis')
for (let i = middleStart; i <= middleEnd; i++) {
pages.push(i)
}
if (middleEnd < totalPages - 2) pages.push('ellipsis')
if (totalPages > 1) pages.push(totalPages - 1)
return pages
}
return (
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href="#"
{...previousLinkProps(table)}
onClick={(event) => {
event.preventDefault()
if (table.getCanPreviousPage()) handlePageChange(currentPageIndex - 1)
}}
/>
</PaginationItem>
{getPageNumbers().map((pageNum, idx) =>
pageNum === 'ellipsis' ? (
<PaginationItem
key={`ellipsis-${
// biome-ignore lint/suspicious/noArrayIndexKey: ellipsis items need index-based keys
idx
}`}
>
<PaginationEllipsis />
</PaginationItem>
) : (
<PaginationItem key={pageNum}>
<PaginationLink
href="#"
onClick={(event) => {
event.preventDefault()
handlePageChange(pageNum as number)
}}
isActive={currentPageIndex === pageNum}
total={table.getPageCount()}
aria-label={`Go to page ${(pageNum as number) + 1}`}
>
{(pageNum as number) + 1}
</PaginationLink>
</PaginationItem>
),
)}
<PaginationItem>
<PaginationNext
href="#"
{...nextLinkProps(table)}
onClick={(event) => {
event.preventDefault()
if (table.getCanNextPage()) handlePageChange(currentPageIndex + 1)
}}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
)
}import type { Table } from '@tanstack/react-table'
import { XIcon } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { statuses } from './data'
import { DataTableFacetedFilter } from './data-table-faceted-filter'
import { DataTableViewOptions } from './data-table-view-options'
interface DataTableToolbarProps<TData> {
table: Table<TData>
}
export function DataTableToolbar<TData>({ table }: DataTableToolbarProps<TData>) {
const isFiltered = table.getState().columnFilters.length > 0
return (
<div className="flex items-center justify-between">
<div className="flex flex-1 items-center gap-2">
<Input
placeholder="Filter cases..."
value={(table.getColumn('name')?.getFilterValue() as string) ?? ''}
onChange={(event) => table.getColumn('name')?.setFilterValue(event.target.value)}
className="w-[150px] lg:w-[250px]"
/>
{table.getColumn('status') && (
<DataTableFacetedFilter column={table.getColumn('status')} title="Status" options={statuses} />
)}
{/* {table.getColumn('priority') && (
<DataTableFacetedFilter column={table.getColumn('priority')} title="Priority" options={priorities} />
)} */}
{isFiltered && (
<Button variant="ghost" onClick={() => table.resetColumnFilters()}>
Reset
<XIcon data-icon="inline-end" />
</Button>
)}
</div>
<DataTableViewOptions table={table} />
</div>
)
}import type { Table } from '@tanstack/react-table'
import { Settings2Icon } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
interface DataTableViewOptionsProps<TData> {
table: Table<TData>
}
export function DataTableViewOptions<TData>({ table }: DataTableViewOptionsProps<TData>) {
return (
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="outline" className="ml-auto hidden lg:flex" />}>
<Settings2Icon data-icon="inline-start" />
View
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[150px]">
<DropdownMenuGroup>
<DropdownMenuLabel>Toggle columns</DropdownMenuLabel>
</DropdownMenuGroup>
<DropdownMenuSeparator />
{table
.getAllColumns()
.filter((column) => typeof column.accessorFn !== 'undefined' && column.getCanHide())
.map((column) => {
return (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize"
checked={column.getIsVisible()}
onCheckedChange={(value) => column.toggleVisibility(!!value)}
>
{column.id}
</DropdownMenuCheckboxItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
)
}'use client'
import {
type ColumnDef,
type ColumnFiltersState,
type ColumnSizingState,
flexRender,
getCoreRowModel,
getExpandedRowModel,
getFacetedRowModel,
getFacetedUniqueValues,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
type OnChangeFn,
type PaginationState,
type RowData,
type SortingState,
type TableMeta,
useReactTable,
type VisibilityState,
} from '@tanstack/react-table'
import { ChevronDownIcon, ChevronRightIcon } from 'lucide-react'
import * as React from 'react'
import { Button } from '@/components/ui/button'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { cn } from '@/lib/utils'
import { DataTablePagination } from './data-table-pagination'
declare module '@tanstack/react-table' {
// biome-ignore lint/correctness/noUnusedVariables: the generics must match TanStack's declaration to merge with it
interface ColumnMeta<TData extends RowData, TValue> {
/** Pin this column to the right edge so it stays visible while the table scrolls horizontally. */
pinRight?: boolean
/** Opt out of the default single-line truncation applied to cells. */
truncate?: boolean
}
}
// Sticky positioning for a right-pinned column. Colours are applied inline (see pinnedCellStyle),
// because `background: inherit` won't track a row's :hover on table cells.
const pinRightStyle: React.CSSProperties = {
position: 'sticky',
right: 0,
}
// Opaque background for a pinned cell that matches the row exactly and hides cells scrolling underneath.
// Selected → solid muted; hovered → the same translucent muted the row uses, composited over an opaque
// base (background-image over background-color) so it matches the row's hover shade while staying opaque.
const MUTED_50 = 'color-mix(in oklch, var(--muted) 50%, transparent)'
// Opaque equivalent of `bg-muted/50` over the page background (used by the muted header and sub-rows).
const MUTED_SURFACE = 'color-mix(in oklch, var(--muted) 50%, var(--background))'
function pinnedCellStyle(muted: boolean, active: boolean, hovered: boolean): React.CSSProperties {
const base = muted ? MUTED_SURFACE : 'var(--background)'
if (active) return { ...pinRightStyle, zIndex: 1, backgroundColor: 'var(--muted)' }
return {
...pinRightStyle,
zIndex: 1,
backgroundColor: base,
backgroundImage: hovered ? `linear-gradient(${MUTED_50}, ${MUTED_50})` : undefined,
}
}
// Width of the leading expander column when getSubRows is used (matches the 3rem cells below).
const EXPANDER_WIDTH = 48
// Smallest a column can be dragged, so it never collapses past a letter + ellipsis ("N…").
const MIN_COLUMN_WIDTH = 48
// Resize grab strip on a header's right edge, with a short divider line: border colour at rest, primary
// colour (thicker) on hover/drag.
function ResizeHandle({ resizeHandler, isResizing }: { resizeHandler: (e: unknown) => void; isResizing: boolean }) {
const [hovered, setHovered] = React.useState(false)
const active = hovered || isResizing
return (
<button
type="button"
aria-label="Resize column"
onMouseDown={resizeHandler}
onTouchStart={resizeHandler}
onClick={(e) => e.stopPropagation()}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{ cursor: 'col-resize' }}
className="absolute inset-y-0 right-0 z-10 flex w-3 touch-none select-none items-stretch justify-end border-0 bg-transparent p-0"
>
<span
style={{
width: active ? 2 : 1,
backgroundColor: active ? 'var(--primary)' : 'var(--border)',
transition: 'width 0.15s, background-color 0.15s',
}}
/>
</button>
)
}
interface DataTableProps<TData extends RowData, TValue> {
/** `muted` (default) gives the header row a `bg-muted/50` surface; `default` keeps it transparent. */
variant?: 'default' | 'muted'
columns: ColumnDef<TData, TValue>[]
data: TData[]
pagination?: PaginationState
pageCount?: number
getSubRows?: (row: TData) => TData[] | undefined
onPaginationChange?: OnChangeFn<PaginationState>
onSortingChange?: OnChangeFn<SortingState>
sorting?: SortingState
manualPagination?: boolean
manualSorting?: boolean
defaultPageSize?: number
meta?: TableMeta<TData>
/** Allow users to drag column borders to resize. On by default; pass false to opt a table out. */
enableColumnResizing?: boolean
}
export function DataTable<TData extends RowData, TValue>({
columns,
data,
pagination,
pageCount,
getSubRows,
onPaginationChange,
onSortingChange,
sorting: initialSorting = [],
manualPagination = false,
manualSorting = false,
defaultPageSize = 10,
variant = 'muted',
meta,
enableColumnResizing = true,
}: DataTableProps<TData, TValue>) {
const [rowSelection, setRowSelection] = React.useState({})
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({})
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([])
const [sorting, setSorting] = React.useState<SortingState>(initialSorting)
const [columnSizing, setColumnSizing] = React.useState<ColumnSizingState>({})
// Once the user drags a column, stop auto-fitting to the container so their chosen widths stick.
const [userResized, setUserResized] = React.useState(false)
const scrollRef = React.useRef<HTMLDivElement>(null)
const safePageSize = defaultPageSize > 0 ? defaultPageSize : 10
const defaultPagination: PaginationState = {
pageIndex: 0,
pageSize: safePageSize,
}
const [internalPagination, setInternalPagination] = React.useState<PaginationState>(defaultPagination)
const handlePaginationChange = pagination ? onPaginationChange : setInternalPagination
const table = useReactTable({
data,
columns,
state: {
pagination: pagination ?? internalPagination,
sorting,
columnVisibility,
rowSelection,
columnFilters,
columnSizing,
},
enableColumnResizing,
columnResizeMode: 'onChange',
onColumnSizingChange: (updater) => {
// A change coming through here is a user drag (our auto-fit sets state directly), so lock in.
setUserResized(true)
setColumnSizing(updater)
},
// Floor the drag width so a column never collapses to nothing — the header keeps at least
// a letter + ellipsis ("N…") instead of fully disappearing.
defaultColumn: { minSize: MIN_COLUMN_WIDTH },
manualPagination,
manualSorting,
pageCount,
onPaginationChange: handlePaginationChange,
onSortingChange: (updater) => {
const newState = typeof updater === 'function' ? updater(sorting) : updater
setSorting(newState)
onSortingChange?.(newState)
},
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: getFacetedUniqueValues(),
getExpandedRowModel: getExpandedRowModel(),
getSubRows,
meta,
})
// Auto-fit: distribute the container width across columns (proportional to their defined sizes) so
// they fill the table at rest. Stops once the user manually resizes, and re-runs on container resize.
// biome-ignore lint/correctness/useExhaustiveDependencies: table identity is stable enough here.
React.useLayoutEffect(() => {
if (!enableColumnResizing || userResized) return
const el = scrollRef.current
if (!el) return
const fitColumns = () => {
const leafs = table.getVisibleLeafColumns()
if (!leafs.length) return
const available = el.clientWidth - (getSubRows ? EXPANDER_WIDTH : 0)
const sum = leafs.reduce((a, c) => a + c.getSize(), 0)
if (available <= 0 || sum <= 0 || available <= sum) return // only grow to fill, never shrink below defined
const next: ColumnSizingState = {}
for (const c of leafs) {
next[c.id] = (c.getSize() / sum) * available
}
setColumnSizing(next)
}
fitColumns()
const ro = new ResizeObserver(fitColumns)
ro.observe(el)
return () => ro.disconnect()
}, [enableColumnResizing, userResized, getSubRows])
// Only pin columns while the table actually overflows horizontally. When everything fits there's no
// scroll to guard against, and a pinned cell (solid bg + border) would just read as an odd extra seam.
const [isOverflowing, setIsOverflowing] = React.useState(false)
React.useLayoutEffect(() => {
const tableEl = scrollRef.current?.querySelector('table')
const scroller = tableEl?.parentElement
if (!tableEl || !scroller) return
const check = () => setIsOverflowing(tableEl.scrollWidth > scroller.clientWidth + 1)
check()
const ro = new ResizeObserver(check)
ro.observe(scroller)
ro.observe(tableEl)
return () => ro.disconnect()
}, [])
// Which row the pointer is over, so a pinned cell can mirror the row's hover tint with inline styles.
const [hoveredRowId, setHoveredRowId] = React.useState<string | null>(null)
return (
<div className="space-y-4">
{/* <DataTableToolbar table={table} /> */}
<div ref={scrollRef} className="rounded-lg border border-border bg-background overflow-hidden">
{/* Size the table to the sum of column widths so columns resize independently and the table
scrolls horizontally instead of squashing neighbours. At rest the auto-fit effect makes
that total equal the container, so the columns fill the width. */}
<Table
className="table-fixed"
style={enableColumnResizing ? { width: table.getTotalSize() + (getSubRows ? EXPANDER_WIDTH : 0) } : undefined}
>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow
key={headerGroup.id}
className={cn(variant === 'muted' && 'bg-muted/50 text-muted-foreground hover:bg-muted/50')}
>
{getSubRows && <TableHead style={{ width: EXPANDER_WIDTH }} />}
{headerGroup.headers.map((header, index) => {
// No handle on the last column: it butts against the table border, so its divider would
// double up with the border and read as an extra empty column.
const isLast = index === headerGroup.headers.length - 1
// Actions columns are pinned everywhere by convention; other columns opt in via meta.pinRight.
// Only while the table overflows horizontally (nothing to pin against otherwise).
const pinRight = isOverflowing && (header.column.columnDef.meta?.pinRight || header.column.id === 'actions')
return (
<TableHead
key={header.id}
id={`th-${header.id}`}
colSpan={header.colSpan}
// A pinned (positioned) cell paints over the row's own border-b, so it carries its own.
// Its opaque background goes through a class (fed by a variable), not an inline background,
// so a theme's header colour still wins.
className={cn(pinRight && 'border-b border-border bg-(--pinned-head-bg)')}
style={
{
width: `${header.getSize()}px`,
...(pinRight
? {
...pinRightStyle,
zIndex: 2,
'--pinned-head-bg': variant === 'muted' ? MUTED_SURFACE : 'var(--background)',
}
: {}),
} as React.CSSProperties
}
>
{/* Relative wrapper (not the <th>, whose position:relative is unreliable across browsers)
so the absolute resize handle positions + stacks correctly. */}
<div className="relative flex h-full items-center pr-1">
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
{header.column.getCanResize() && !isLast && (
<ResizeHandle
resizeHandler={header.getResizeHandler() as (e: unknown) => void}
isResizing={header.column.getIsResizing()}
/>
)}
</div>
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row, rowIndex) => {
const isLastRow = rowIndex === table.getRowModel().rows.length - 1
return (
<TableRow
key={row.id}
data-state={row.getIsSelected() && 'selected'}
className={cn(row.depth > 0 && 'bg-muted/50 text-muted-foreground')}
onMouseEnter={() => setHoveredRowId(row.id)}
onMouseLeave={() => setHoveredRowId((id) => (id === row.id ? null : id))}
>
{getSubRows && (
<TableCell style={{ width: EXPANDER_WIDTH }}>
{row.getCanExpand() && (
<Button
type="button"
aria-label={row.getIsExpanded() ? 'Collapse row' : 'Expand row'}
aria-expanded={row.getIsExpanded()}
onClick={row.getToggleExpandedHandler()}
size="icon-sm"
variant="ghost"
>
{row.getIsExpanded() ? <ChevronDownIcon /> : <ChevronRightIcon />}
</Button>
)}
</TableCell>
)}
{row.getVisibleCells().map((cell) => {
const skipTruncate =
cell.column.id === 'actions' ||
cell.column.id === 'availability' ||
cell.column.columnDef.meta?.truncate === false
const pinRight = isOverflowing && (cell.column.columnDef.meta?.pinRight || cell.column.id === 'actions')
return (
<TableCell
key={cell.id}
headers={`th-${cell.column.id}`}
// Pinned cells paint over the row's border-b, so carry a matching one (except last row).
className={pinRight && !isLastRow ? 'border-b border-border' : undefined}
style={pinRight ? pinnedCellStyle(row.depth > 0, row.getIsSelected(), hoveredRowId === row.id) : undefined}
>
{skipTruncate ? (
flexRender(cell.column.columnDef.cell, cell.getContext())
) : (
<div className="truncate">{flexRender(cell.column.columnDef.cell, cell.getContext())}</div>
)}
</TableCell>
)
})}
</TableRow>
)
})
) : (
<TableRow>
<TableCell colSpan={columns.length + (getSubRows ? 1 : 0)} className="h-24 text-center">
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{table.getPageCount() > 1 && (
<div className="flex flex-row justify-end">
<DataTablePagination onPaginationChange={handlePaginationChange} table={table} />
</div>
)}
</div>
)
}import {
ArrowDownIcon,
ArrowRightIcon,
ArrowUpIcon,
CircleCheckIcon,
CircleDashedIcon,
CircleIcon,
CircleXIcon,
HourglassIcon,
TimerIcon,
} from 'lucide-react'
/** Case types, e.g. for a faceted filter on a case list. */
export const labels = [
{
value: 'building-permit',
label: 'Building permit',
},
{
value: 'environmental-permit',
label: 'Environmental permit',
},
{
value: 'records-request',
label: 'Public records request',
},
]
/** Case statuses, from registration to closing. */
export const statuses = [
{
value: 'received',
label: 'Received',
icon: CircleDashedIcon,
},
{
value: 'in-review',
label: 'In review',
icon: TimerIcon,
},
{
value: 'supplement',
label: 'Waiting for supplement',
icon: HourglassIcon,
},
{
value: 'decided',
label: 'Decided',
icon: CircleCheckIcon,
},
{
value: 'closed',
label: 'Closed',
icon: CircleIcon,
},
{
value: 'withdrawn',
label: 'Withdrawn',
icon: CircleXIcon,
},
]
export const priorities = [
{
label: 'Low',
value: 'low',
icon: ArrowDownIcon,
},
{
label: 'Normal',
value: 'normal',
icon: ArrowRightIcon,
},
{
label: 'Urgent',
value: 'urgent',
icon: ArrowUpIcon,
},
]export type {
Column,
ColumnDef,
ColumnFiltersState,
SortingState,
VisibilityState,
} from '@tanstack/react-table'
export * from './creator-name-cell'
export * from './data'
export * from './data-table'
export * from './data-table-column-header'
export * from './data-table-faceted-filter'
export * from './data-table-pagination'
export * from './data-table-toolbar'
export * from './truncated-tooltip''use client'
import * as React from 'react'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
/** Single-line ellipsis: truncated when the content is wider than its clipped box. */
export function isEllipsisTruncated(el: HTMLElement | null): boolean {
return !!el && el.scrollWidth > el.clientWidth + 1
}
/**
* Multi-line `-webkit-line-clamp`: truncated when the full text is taller than the clamped box.
* Safari reports `scrollHeight === clientHeight` for clamped boxes, so lift the clamp to measure
* the true height, then restore it.
*/
export function isLineClampTruncated(el: HTMLElement | null): boolean {
if (!el) return false
const clampedHeight = el.clientHeight
el.style.webkitLineClamp = '9999'
const fullHeight = el.scrollHeight
el.style.webkitLineClamp = ''
return fullHeight > clampedHeight + 1
}
interface TruncatedTooltipProps {
/** Tooltip content — usually the full, untruncated text. */
content: React.ReactNode
/** `true` = multi-line line-clamp; `false` (default) = single-line ellipsis. */
clamp?: boolean
/** Delay in ms before the tooltip opens (passed to Base UI's `TooltipTrigger` `delay`). */
delayDuration?: number
side?: 'top' | 'right' | 'bottom' | 'left'
align?: 'start' | 'center' | 'end'
/** Render the trigger; attach the passed `ref` to the element that is clipped/truncated. */
children: (ref: React.RefCallback<HTMLElement>) => React.ReactElement
}
/**
* Wraps a truncatable trigger and shows a tooltip ONLY when the text is actually truncated.
* Measured on demand (hover/focus), when the element is guaranteed laid out and visible — this
* avoids the layout-timing races of measuring inside an effect. The tooltip is non-interactive
* (`pointer-events-none`) so it never blocks clicks on the rows beneath it.
*/
export function TruncatedTooltip({
content,
clamp = false,
delayDuration,
side = 'bottom',
align = 'start',
children,
}: Readonly<TruncatedTooltipProps>) {
const ref = React.useRef<HTMLElement | null>(null)
const [open, setOpen] = React.useState(false)
const check = () => (clamp ? isLineClampTruncated(ref.current) : isEllipsisTruncated(ref.current))
// Stable ref callback so the trigger isn't detached/reattached on every render.
const setRef = React.useCallback<React.RefCallback<HTMLElement>>((el) => {
ref.current = el
}, [])
return (
<Tooltip open={open} onOpenChange={(next) => setOpen(next && check())}>
<TooltipTrigger delay={delayDuration} render={children(setRef)} />
<TooltipContent side={side} align={align} className="pointer-events-none">
{content}
</TooltipContent>
</Tooltip>
)
}A TanStack Table wrapper on top of the Table primitive. It installs as a folder
(components/ui/data-table/) with the table itself plus column header, toolbar, faceted filter, view options,
pagination and a truncation-aware tooltip helper.
Features
- Client-side or manual (server-driven) sorting and pagination
- Expandable rows via
getSubRows - Drag-to-resize columns (auto-fit to the container until the user resizes)
- Right-pinned
actionscolumn (or any column withmeta.pinRight) while the table overflows horizontally - Single-line truncation of cells by default (opt out with
meta.truncate: false) - Faceted filters and column visibility toggles through
DataTableToolbar/DataTableViewOptions
Usage
import { DataTable, DataTableColumnHeader, type ColumnDef } from "@/components/ui/data-table"
type CaseRow = { id: string; subject: string; status: string }
const columns: ColumnDef<CaseRow>[] = [
{
accessorKey: 'id',
header: ({ column }) => <DataTableColumnHeader column={column} title="Case" />,
size: 110,
},
{
accessorKey: 'subject',
header: ({ column }) => <DataTableColumnHeader column={column} title="Subject" />,
},
{
id: 'actions',
header: () => <span className="sr-only">Actions</span>,
cell: ({ row }) => <CaseMenu caseRow={row.original} />,
size: 56,
enableResizing: false,
},
]
<DataTable columns={columns} data={cases} defaultPageSize={10} />
// Server-driven pagination + sorting
<DataTable
columns={columns}
data={page.items}
pageCount={page.totalPages}
pagination={pagination}
onPaginationChange={setPagination}
sorting={sorting}
onSortingChange={setSorting}
manualPagination
manualSorting
/>Examples
Default
A case list with sortable columns and pagination. The status badges pair an icon with the label, so the status doesn't rely on colour alone.
With actions column
More columns than fit, with a row menu in the actions column. That column is pinned to the right edge whenever the table scrolls horizontally.
Expandable rows
Cases with sub-rows (consultations and inspections) via getSubRows.
Without column resizing
With enableColumnResizing={false} and the transparent default header.
Single page
The pagination is hidden when every row fits on one page.
Empty
Column meta
| Key | Type | Description |
|---|---|---|
pinRight | boolean | Keep the column visible at the right edge while scrolling horizontally |
truncate | boolean | Set to false to opt out of single-line truncation for the cell |
Related Components
- Table
- Pagination
- Tooltip