emstein-ui
componenttablesource: ok · hit
Table
A virtualized shadcn-compatible table with sorting, resizing, and optional selection.
npx shadcn add "https://ui.emstein.com/r/table.json"free
Where it goes
data-table
Interaction
dropdown
Use cases
dashboard
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lighticonslucide-react
Registry dependencies
checkbox
Files1 · source cached 10/14/2026, 12:04:39 PM
components/emstein-ui/table.tsx
"use client"
import * as React from "react"
import {
type ColumnDef,
type ColumnSizingState,
type RowSelectionState,
type SortingState,
flexRender,
getCoreRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table"
import { useVirtualizer, useWindowVirtualizer } from "@tanstack/react-virtual"
import { ChevronDown } from "lucide-react"
import { Checkbox } from "@/components/ui/checkbox"
import { cn } from "@/lib/utils"
type Align = "left" | "center" | "right"
type SortDirection = "asc" | "desc"
type ColumnType =
| "text"
| "longText"
| "shortText"
| "number"
| "currency"
| "date"
| "status"
| "actions"
export type EmsteinUITableColumn<TData> = {
key: Extract<keyof TData, string> | string
label: string
type?: ColumnType
grow?: boolean | number
width?: number
minWidth?: number
sortable?: boolean
resizable?: boolean
align?: Align
render?: (row: TData) => React.ReactNode
footer?:
| React.ReactNode
| ((context: { data: TData[]; rows: TData[] }) => React.ReactNode)
className?: string
footerClassName?: string
}
export type EmsteinUITableSelection = {
selectedRows?: string[]
onSelectedRowsChange?: (rows: string[]) => void
defaultSelectedRows?: string[]
}
export type EmsteinUITableProps<TData> = {
data: TData[]
columns: EmsteinUITableColumn<TData>[]
getRowId?: (row: TData, index: number) => string
selection?: EmsteinUITableSelection
resizable?: boolean
sortable?: boolean
showUnsortedSortIcon?: boolean
initialSort?: { key: string; direction?: SortDirection } | false
scrollMode?: "page" | "table"
tableHeight?: number
rowHeight?: number
emptyText?: string
caption?: React.ReactNode
className?: string
containerClassName?: string
tableClassName?: string
headerClassName?: string
bodyClassName?: string
footerClassName?: string
captionClassName?: string
}
export type TablePrimitiveProps = React.ComponentProps<"table"> & {
containerClassName?: string
}
const alignClass: Record<Align, string> = {
left: "justify-start text-left",
center: "justify-center text-center",
right: "justify-end text-right",
}
const columnTypeSizes: Record<
ColumnType,
{ width: number; minWidth: number; grow: number }
> = {
text: { width: 180, minWidth: 120, grow: 1 },
longText: { width: 280, minWidth: 180, grow: 2 },
shortText: { width: 140, minWidth: 96, grow: 0.5 },
number: { width: 104, minWidth: 80, grow: 0 },
currency: { width: 124, minWidth: 96, grow: 0 },
date: { width: 132, minWidth: 112, grow: 0 },
status: { width: 128, minWidth: 104, grow: 0 },
actions: { width: 72, minWidth: 56, grow: 0 },
}
function getColumnSizes<TData>(column: EmsteinUITableColumn<TData>) {
const preset = columnTypeSizes[column.type ?? "text"]
return {
width: column.width ?? preset.width,
minWidth: column.minWidth ?? preset.minWidth,
}
}
function getColumnGrow<TData>(column: EmsteinUITableColumn<TData>) {
if (typeof column.grow === "number") {
return Math.max(0, column.grow)
}
if (column.grow === true) {
return 1
}
if (column.grow === false) {
return 0
}
return columnTypeSizes[column.type ?? "text"].grow
}
function getValue<TData>(row: TData, key: string) {
return (row as Record<string, unknown>)[key]
}
function toRowSelection(ids: string[]): RowSelectionState {
return ids.reduce<RowSelectionState>((state, id) => {
state[id] = true
return state
}, {})
}
function getNextValue<TValue>(
updater: TValue | ((old: TValue) => TValue),
old: TValue
) {
return typeof updater === "function"
? (updater as (value: TValue) => TValue)(old)
: updater
}
export function TableHeader({
className,
...props
}: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
export function TableBody({
className,
...props
}: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
export function TableFooter({
className,
...props
}: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
export function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
)
}
export function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"h-12 px-3 text-left align-middle text-xs font-medium uppercase tracking-wider whitespace-nowrap text-muted-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
export function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-3 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
export function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function PrimitiveTable({
className,
containerClassName,
...props
}: TablePrimitiveProps) {
return (
<div
data-slot="table-container"
className={cn("relative w-full overflow-x-auto", containerClassName)}
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function DataTable<TData>({
data,
columns,
getRowId,
selection,
resizable = true,
sortable = true,
showUnsortedSortIcon = false,
initialSort,
scrollMode = "page",
tableHeight = 560,
rowHeight = 56,
emptyText = "No results.",
caption,
className,
containerClassName,
tableClassName,
headerClassName,
bodyClassName,
footerClassName,
captionClassName,
}: EmsteinUITableProps<TData>) {
const isSelectionControlled = selection?.selectedRows !== undefined
const [internalSelectedRows, setInternalSelectedRows] = React.useState(
selection?.defaultSelectedRows ?? []
)
const selectedRows = selection?.selectedRows ?? internalSelectedRows
const rowSelection = React.useMemo(
() => toRowSelection(selectedRows),
[selectedRows]
)
const hasSelection = selection !== undefined
const initialSorting = React.useMemo<SortingState>(() => {
if (!sortable || initialSort === false) {
return []
}
if (initialSort?.key) {
return [{ id: initialSort.key, desc: initialSort.direction === "desc" }]
}
const firstSortableColumn = columns.find((column) => column.sortable !== false)
return firstSortableColumn
? [{ id: String(firstSortableColumn.key), desc: false }]
: []
}, [columns, initialSort, sortable])
const [sorting, setSorting] = React.useState<SortingState>(initialSorting)
const [columnSizing, setColumnSizing] = React.useState<ColumnSizingState>({})
const tableColumns = React.useMemo<ColumnDef<TData>[]>(
() => [
...(hasSelection
? [
{
id: "_select",
size: 44,
minSize: 44,
maxSize: 44,
enableSorting: false,
enableResizing: false,
header: ({ table }) => (
<Checkbox
aria-label="Select all rows"
checked={
table.getIsAllRowsSelected()
? true
: table.getIsSomeRowsSelected()
? "indeterminate"
: false
}
onCheckedChange={(value) =>
table.toggleAllRowsSelected(Boolean(value))
}
/>
),
cell: ({ row }) => (
<Checkbox
aria-label="Select row"
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(Boolean(value))}
/>
),
} satisfies ColumnDef<TData>,
]
: []),
...columns.map(
(column): ColumnDef<TData> => {
const sizes = getColumnSizes(column)
return {
id: String(column.key),
accessorFn: (row) => getValue(row, String(column.key)),
size: sizes.width,
minSize: sizes.minWidth,
enableSorting: sortable && column.sortable !== false,
enableResizing: resizable && column.resizable !== false,
header: column.label,
cell: ({ row }) => {
const value = getValue(row.original, String(column.key))
return column.render
? column.render(row.original)
: String(value ?? "")
},
meta: {
align: column.align ?? "left",
className: column.className,
grow: getColumnGrow(column),
},
}
}
),
],
[columns, hasSelection, resizable, sortable]
)
const table = useReactTable({
data,
columns: tableColumns,
state: {
sorting,
columnSizing,
rowSelection,
},
defaultColumn: {
minSize: 96,
size: 160,
},
getRowId: (row, index) => getRowId?.(row, index) ?? String(index),
enableRowSelection: hasSelection,
enableSortingRemoval: false,
columnResizeMode: "onChange",
onSortingChange: setSorting,
onColumnSizingChange: setColumnSizing,
onRowSelectionChange: (updater) => {
const next = getNextValue(updater, rowSelection)
const ids = Object.keys(next).filter((id) => next[id])
if (!isSelectionControlled) {
setInternalSelectedRows(ids)
}
selection?.onSelectedRowsChange?.(ids)
},
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
})
const rows = table.getRowModel().rows
const renderedRows = React.useMemo(
() => rows.map((row) => row.original),
[rows]
)
const tableRootRef = React.useRef<HTMLDivElement>(null)
const tableScrollRef = React.useRef<HTMLDivElement>(null)
const [scrollMargin, setScrollMargin] = React.useState(0)
const [containerWidth, setContainerWidth] = React.useState(0)
React.useLayoutEffect(() => {
if (scrollMode !== "page" || !tableRootRef.current) {
return
}
const measure = () =>
setScrollMargin(
tableRootRef.current
? tableRootRef.current.getBoundingClientRect().top + window.scrollY
: 0
)
measure()
window.addEventListener("resize", measure)
return () => window.removeEventListener("resize", measure)
}, [scrollMode])
React.useLayoutEffect(() => {
if (!tableRootRef.current) {
return
}
const root = tableRootRef.current
const updateWidth = () => setContainerWidth(root.clientWidth)
const observer = new ResizeObserver(updateWidth)
updateWidth()
observer.observe(root)
return () => observer.disconnect()
}, [])
const windowVirtualizer = useWindowVirtualizer({
count: rows.length,
estimateSize: () => rowHeight,
overscan: 12,
scrollMargin,
enabled: scrollMode === "page",
initialRect: { width: 1200, height: 800 },
})
const tableVirtualizer = useVirtualizer({
count: rows.length,
estimateSize: () => rowHeight,
overscan: 12,
getScrollElement: () => tableScrollRef.current,
enabled: scrollMode === "table",
})
const virtualizer =
scrollMode === "page" ? windowVirtualizer : tableVirtualizer
const virtualRows = virtualizer.getVirtualItems()
const totalSize = virtualizer.getTotalSize()
const leafHeaders = table.getFlatHeaders()
const visibleLeafHeaders = table.getVisibleLeafColumns()
const rawTableWidth = table.getTotalSize()
const tableWidth = Math.max(rawTableWidth, containerWidth)
const extraWidth = Math.max(0, tableWidth - rawTableWidth)
const growByColumn = React.useMemo(() => {
return Object.fromEntries(
leafHeaders.map((header) => {
const meta = header.column.columnDef.meta as
| { grow?: number }
| undefined
return [header.column.id, meta?.grow ?? 0]
})
)
}, [leafHeaders])
const totalGrow = React.useMemo(
() => Object.values(growByColumn).reduce((total, grow) => total + grow, 0),
[growByColumn]
)
const getColumnWidth = React.useCallback(
(columnId: string, size: number) => {
if (!extraWidth || !totalGrow) {
return size
}
return size + extraWidth * ((growByColumn[columnId] ?? 0) / totalGrow)
},
[extraWidth, growByColumn, totalGrow]
)
const hasFooter = columns.some((column) => column.footer !== undefined)
function resizeWithNeighbor(
headerIndex: number,
event: React.PointerEvent<HTMLButtonElement>
) {
const header = leafHeaders[headerIndex]
const nextHeader = leafHeaders[headerIndex + 1]
if (!header?.column.getCanResize() || !nextHeader) {
return
}
event.preventDefault()
event.currentTarget.setPointerCapture(event.pointerId)
const startX = event.clientX
const startSize = getColumnWidth(header.column.id, header.column.getSize())
const nextStartSize = getColumnWidth(
nextHeader.column.id,
nextHeader.column.getSize()
)
const minSize = header.column.columnDef.minSize ?? 96
const nextMinSize = nextHeader.column.columnDef.minSize ?? 96
const onPointerMove = (moveEvent: PointerEvent) => {
const delta = moveEvent.clientX - startX
const clampedDelta = Math.min(
Math.max(delta, minSize - startSize),
nextStartSize - nextMinSize
)
setColumnSizing((current) => ({
...current,
[header.column.id]: startSize + clampedDelta,
[nextHeader.column.id]: nextStartSize - clampedDelta,
}))
}
const onPointerUp = () => {
window.removeEventListener("pointermove", onPointerMove)
window.removeEventListener("pointerup", onPointerUp)
}
window.addEventListener("pointermove", onPointerMove)
window.addEventListener("pointerup", onPointerUp)
}
return (
<div
ref={tableRootRef}
className={cn(
"relative w-full border",
scrollMode === "page" && "overflow-x-auto overflow-y-clip",
scrollMode === "table" && "overflow-hidden",
className,
containerClassName
)}
>
<div
ref={tableScrollRef}
className={cn(
scrollMode === "table" && "overflow-auto",
scrollMode === "page" && "overflow-visible"
)}
style={scrollMode === "table" ? { height: tableHeight } : undefined}
>
<table
className={cn("grid w-full caption-bottom text-sm", tableClassName)}
style={{ minWidth: rawTableWidth, width: tableWidth }}
>
{caption ? (
<TableCaption className={captionClassName}>{caption}</TableCaption>
) : null}
<TableHeader className={cn("grid bg-muted", headerClassName)}>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} className="flex">
{headerGroup.headers.map((header, headerIndex) => {
const canSort = header.column.getCanSort()
const sortState = header.column.getIsSorted()
const meta = header.column.columnDef.meta as
| { align?: Align }
| undefined
const align = meta?.align ?? "left"
const showSortIcon =
sortState || (showUnsortedSortIcon && canSort)
const renderedHeader = flexRender(
header.column.columnDef.header,
header.getContext()
)
return (
<TableHead
key={header.id}
className={cn(
"group relative flex items-center px-3",
canSort && "cursor-pointer",
alignClass[align]
)}
style={{
width: getColumnWidth(header.column.id, header.getSize()),
minWidth: header.column.columnDef.minSize,
}}
>
{canSort ? (
<button
type="button"
aria-label={`Sort ${
typeof header.column.columnDef.header === "string"
? header.column.columnDef.header
: "column"
}`}
className="absolute inset-0 z-0 cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
onClick={header.column.getToggleSortingHandler()}
/>
) : null}
{headerIndex > 0 ? (
<span
aria-hidden="true"
className="pointer-events-none absolute inset-y-2 left-0 z-10 w-px bg-ring opacity-0 transition-opacity group-hover:opacity-100"
/>
) : null}
{headerIndex < leafHeaders.length - 1 ? (
<span
aria-hidden="true"
className="pointer-events-none absolute inset-y-2 right-0 z-10 w-px bg-ring opacity-0 transition-opacity group-hover:opacity-100"
/>
) : null}
<div className="pointer-events-none relative z-10 inline-flex min-w-0 items-center gap-1.5 text-xs font-medium uppercase tracking-wider">
<span className="truncate">
{renderedHeader}
</span>
{showSortIcon ? (
<ChevronDown
data-icon="inline-end"
className={cn(
"transition-transform duration-200 ease-out",
sortState === "asc" && "rotate-180",
!sortState && "opacity-60"
)}
/>
) : null}
</div>
{resizable &&
header.column.getCanResize() &&
headerIndex < leafHeaders.length - 1 ? (
<button
type="button"
aria-label="Resize column"
className="absolute inset-y-0 right-0 z-20 w-3 cursor-col-resize outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
onPointerDown={(event) =>
resizeWithNeighbor(headerIndex, event)
}
/>
) : null}
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>
<TableBody
className={cn("relative grid", bodyClassName)}
style={{ height: totalSize }}
>
{virtualRows.length ? (
virtualRows.map((virtualRow) => {
const row = rows[virtualRow.index]
return (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
className="absolute flex w-full"
style={{
height: virtualRow.size,
transform: `translateY(${
scrollMode === "page"
? virtualRow.start - scrollMargin
: virtualRow.start
}px)`,
}}
>
{row.getVisibleCells().map((cell) => {
const meta = cell.column.columnDef.meta as
| { align?: Align; className?: string }
| undefined
const align = meta?.align ?? "left"
return (
<TableCell
key={cell.id}
className={cn(
"flex items-center px-3",
alignClass[align],
meta?.className
)}
style={{
width: getColumnWidth(
cell.column.id,
cell.column.getSize()
),
minWidth: cell.column.columnDef.minSize,
}}
>
<span className="truncate">
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</span>
</TableCell>
)
})}
</TableRow>
)
})
) : (
<TableRow className="flex">
<TableCell className="flex h-24 w-full items-center justify-center text-muted-foreground">
{emptyText}
</TableCell>
</TableRow>
)}
</TableBody>
{hasFooter ? (
<TableFooter className={cn("grid bg-muted", footerClassName)}>
<TableRow className="flex">
{hasSelection ? (
<TableCell
className="flex items-center px-3"
style={{
width: getColumnWidth(
visibleLeafHeaders[0].id,
visibleLeafHeaders[0].getSize()
),
minWidth: visibleLeafHeaders[0].columnDef.minSize,
}}
/>
) : null}
{columns.map((column) => {
const tableColumn = table.getColumn(String(column.key))
if (!tableColumn) {
return null
}
const align = column.align ?? "left"
const footer =
typeof column.footer === "function"
? column.footer({ data, rows: renderedRows })
: column.footer
return (
<TableCell
key={String(column.key)}
className={cn(
"flex items-center px-3",
alignClass[align],
column.footerClassName
)}
style={{
width: getColumnWidth(
tableColumn.id,
tableColumn.getSize()
),
minWidth: tableColumn.columnDef.minSize,
}}
>
<span className="truncate">{footer}</span>
</TableCell>
)
})}
</TableRow>
</TableFooter>
) : null}
</table>
</div>
</div>
)
}
export function Table<TData>(
props: EmsteinUITableProps<TData> | TablePrimitiveProps
) {
if ("data" in props && "columns" in props) {
return <DataTable {...props} />
}
return <PrimitiveTable {...props} />
}