rapidUI

Data Table

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.json

Installs to @/components/ui/data-table.

Manual installation

Install the dependencies:

pnpm add @tanstack/react-table@^8.21.3 lucide-react@^1.48.0

Add the components it builds on: badge, button, command, dropdown-menu, input, pagination, popover, separator, table, tooltip.

Copy the source into your project:

components/ui/data-table/creator-name-cell.tsx
'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>
}
components/ui/data-table/data-table-column-header.tsx
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>
	)
}
components/ui/data-table/data-table-faceted-filter.tsx
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>
	)
}
components/ui/data-table/data-table-pagination.tsx
'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>
	)
}
components/ui/data-table/data-table-toolbar.tsx
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>
	)
}
components/ui/data-table/data-table-view-options.tsx
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>
	)
}
components/ui/data-table/data-table.tsx
'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>
	)
}
components/ui/data-table/data.ts
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,
	},
]
components/ui/data-table/index.ts
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'
components/ui/data-table/truncated-tooltip.tsx
'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 actions column (or any column with meta.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

KeyTypeDescription
pinRightbooleanKeep the column visible at the right edge while scrolling horizontally
truncatebooleanSet to false to opt out of single-line truncation for the cell
  • Table
  • Pagination
  • Tooltip