Table
Virtualized data table that stays smooth at 10k+ rows, with sortable headers, row selection, column resize and reorder, and a sticky header. Minimal, reduced-motion-safe motion.
Data Table
index.tsx10k virtualized rows with sortable headers, row selection, column resize and reorder.
Email | Status |
|---|
"use client";
import { useMemo, useState } from "react";
import { Table, type TableColumn } from "@/components/motion/table";
import { cn } from "@/lib/utils";
type Person = {
id: string;
name: string;
email: string;
role: string;
status: "active" | "invited" | "suspended";
mrr: number;
};
const FIRST = [
"Ava",
"Leo",
"Mia",
"Kai",
"Zoe",
"Eli",
"Noa",
"Ren",
"Ivy",
"Jude",
];
const LAST = [
"Cole",
"Frost",
"Vale",
"Reyes",
"Okafor",
"Sato",
"Lund",
"Marsh",
"Bose",
"Quinn",
];
const ROLES = ["Owner", "Admin", "Member", "Viewer"];
const STATUSES: Person["status"][] = ["active", "invited", "suspended"];
// Deterministic so SSR and client render the same rows (no hydration drift).
function buildPeople(count: number): Person[] {
const out: Person[] = [];
for (let i = 0; i < count; i++) {
const first = FIRST[i % FIRST.length];
const last = LAST[(i * 7) % LAST.length];
out.push({
id: String(i),
name: `${first} ${last}`,
email: `${first.toLowerCase()}.${last.toLowerCase()}${i}@beui.dev`,
role: ROLES[(i * 3) % ROLES.length],
status: STATUSES[(i * 5) % STATUSES.length],
mrr: 12 + ((i * 37) % 488),
});
}
return out;
}
const STATUS_STYLES: Record<Person["status"], string> = {
active: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400",
invited: "bg-amber-500/10 text-amber-600 dark:text-amber-400",
suspended: "bg-rose-500/10 text-rose-600 dark:text-rose-400",
};
function StatusBadge({ status }: { status: Person["status"] }) {
return (
<span
className={cn(
"rounded-full px-2 py-0.5 font-medium text-xs capitalize",
STATUS_STYLES[status],
)}
>
{status}
</span>
);
}
export function TablePreview() {
const data = useMemo(() => buildPeople(10_000), []);
const [selected, setSelected] = useState<string[]>([]);
const columns = useMemo<TableColumn<Person>[]>(
() => [
{
key: "name",
header: "Name",
sortable: true,
width: "1.4fr",
cell: (row) => <span className="font-medium">{row.name}</span>,
},
{ key: "email", header: "Email", width: "1.8fr" },
{ key: "role", header: "Role", sortable: true, width: "120px" },
{
key: "status",
header: "Status",
width: "130px",
cell: (row) => <StatusBadge status={row.status} />,
},
{
key: "mrr",
header: "MRR",
sortable: true,
align: "right",
width: "110px",
cell: (row) => (
<span className="tabular-nums">${row.mrr.toLocaleString()}</span>
),
},
],
[],
);
return (
<div className="flex w-full justify-center p-4">
<div className="flex w-full flex-col gap-2">
<div className="flex items-center justify-between px-1 text-muted-foreground text-xs">
<span>{data.length.toLocaleString()} rows</span>
{selected.length > 0 ? (
<span>{selected.length.toLocaleString()} selected</span>
) : null}
</div>
<Table
data={data}
columns={columns}
selectable
resizable
reorderable
selectedRowIds={selected}
onSelectionChange={setSelected}
defaultSort={{ key: "mrr", direction: "desc" }}
height={420}
rowHeight={52}
className="rounded-2xl"
/>
</div>
</div>
);
}
"use client";
// beui.dev/components/motion/table
import { useVirtualizer } from "@tanstack/react-virtual";
import { useReducedMotion } from "motion/react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Checkbox } from "@/components/motion/checkbox";
import { cn } from "@/lib/utils";
import { EditableCell } from "./editable-cell";
import { RowHandle } from "./row-handle";
import { SkeletonRows } from "./skeleton-rows";
import { TableHeader } from "./table-header";
import type { HeaderCellRefs, TableProps } from "./types";
import { useColumnReorder } from "./use-column-reorder";
import { useColumnResize } from "./use-column-resize";
import { useColumnSort } from "./use-column-sort";
import { useRowSelection } from "./use-row-selection";
import { alignText, CHECKBOX_PX, CHECKBOX_WIDTH, readCell } from "./utils";
export type {
SortDirection,
SortState,
TableColumn,
TableProps,
} from "./types";
/**
* Narrowest a column of bare inputs may be floored to and still show a value:
* the cell's own `px-4` eats 32 of it.
*/
const INPUT_COLUMN_WIDTH = 120;
/** The root font size Tailwind's rem scale assumes, and the pre-measure guess. */
const DEFAULT_ROOT_FONT_SIZE = 16;
/**
* What one `rem` is worth here, in px. The default until the first client
* layout, so the server and the hydrating client emit the same floor; measured
* once after that, because a document that sets its own `html { font-size }`
* lays a rem column out against that size and a floor computed from 16 would
* fall short by the same factor.
*/
function useRootFontSize() {
const [size, setSize] = useState(DEFAULT_ROOT_FONT_SIZE);
useEffect(() => {
const measured = Number.parseFloat(
getComputedStyle(document.documentElement).fontSize,
);
if (measured > 0) setSize(measured);
}, []);
return size;
}
/**
* The absolute width a column declared, in px, or null when it declared a share
* of the remainder instead (`fr`, `%`, `auto`, `calc()`, nothing at all) — those
* are worth whatever is left over, which is not a width this can add up.
*/
function resolveColumnWidth(
width: string | undefined,
rootFontSize: number,
): number | null {
if (!width) return null;
const value = Number.parseFloat(width);
if (!Number.isFinite(value)) return null;
if (width.endsWith("px")) return value;
// rem is the other absolute length the repo writes.
if (width.endsWith("rem")) return value * rootFontSize;
return null;
}
export function Table<T>({
data,
columns,
getRowId,
selectable = false,
selectedRowIds,
defaultSelectedRowIds,
onSelectionChange,
sort: sortProp,
defaultSort = null,
onSortChange,
resizable = false,
minColumnWidth = 64,
onColumnResize,
reorderable = false,
onColumnOrderChange,
onCellEdit,
onColumnRename,
onInsertRow,
onDeleteRow,
onInsertColumn,
onDeleteColumn,
rowHeight = 48,
height = 440,
overscan = 10,
onEndReached,
loading = false,
skeletonRows = 3,
emptyState = "No data",
className,
}: TableProps<T>) {
const reduce = useReducedMotion();
const scrollRef = useRef<HTMLDivElement>(null);
const thRefs: HeaderCellRefs = useRef<
Record<string, HTMLTableCellElement | null>
>({});
const rows = useMemo(
() =>
data.map((row, index) => ({
row,
id: getRowId ? getRowId(row, index) : String(index),
})),
[data, getRowId],
);
const {
orderedColumns,
dragKey,
dropIndex,
startReorder,
moveReorder,
endReorder,
} = useColumnReorder({ columns, thRefs, onColumnOrderChange });
const { sort, sortedRows, toggleSort } = useColumnSort({
rows,
columns,
sort: sortProp,
defaultSort,
onSortChange,
});
const { widths, startResize, moveResize, endResize } = useColumnResize({
orderedColumns,
thRefs,
minColumnWidth,
onColumnResize,
});
const { selected, allSelected, someSelected, toggleAll, toggleRow } =
useRowSelection({
sortedRows,
selectedRowIds,
defaultSelectedRowIds,
onSelectionChange,
});
const virtualizer = useVirtualizer({
count: sortedRows.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => rowHeight,
overscan,
});
const virtualItems = virtualizer.getVirtualItems();
const totalSize = virtualizer.getTotalSize();
const paddingTop = virtualItems.length > 0 ? virtualItems[0].start : 0;
const paddingBottom =
virtualItems.length > 0
? totalSize - virtualItems[virtualItems.length - 1].end
: 0;
const hasRowMenu = !!(onInsertRow || onDeleteRow);
const hasColumnMenu = !!(onInsertColumn || onDeleteColumn);
// Only shrink-wrap (w-max) once every column has an explicit resized width;
// otherwise stay fill-width so a flexible column can't size to cell content.
const sized =
orderedColumns.length > 0 &&
orderedColumns.every((c) => widths[c.key] != null);
const rootFontSize = useRootFontSize();
// In a container narrower than the columns, `table-layout: fixed` shrinks
// every column toward zero instead of scrolling. Floor the table at what the
// columns actually asked for — an absolute declared width where there is one,
// and for the ones sharing the remainder whatever content they can fall back
// on — then let the viewport scroll past it.
const minTableWidth = useMemo(() => {
// A column whose cells render bare inputs has no content for the fallback
// to measure, which is exactly the column that collapses; a renamable
// header is an input too, and in a fixed layout the header row is what
// sizes the column.
const inputOnly = (column: (typeof orderedColumns)[number]) =>
Boolean(onColumnRename) || (!column.cell && Boolean(column.editable));
const total = orderedColumns.reduce((sum, column) => {
const resized = widths[column.key];
if (resized != null) return sum + resized;
const declared = resolveColumnWidth(column.width, rootFontSize);
if (declared != null) return sum + declared;
return (
sum +
(inputOnly(column)
? Math.max(minColumnWidth, INPUT_COLUMN_WIDTH)
: minColumnWidth)
);
}, selectable ? CHECKBOX_PX : 0);
return Math.round(total);
}, [
minColumnWidth,
onColumnRename,
orderedColumns,
rootFontSize,
selectable,
widths,
]);
// Infinite scroll: fire onEndReached once per near-bottom dwell, paused while
// loading; the guard resets when the load completes.
const endReachedRef = useRef(false);
useEffect(() => {
if (!loading) endReachedRef.current = false;
}, [loading]);
const handleScroll = useCallback(() => {
const el = scrollRef.current;
if (!el || !onEndReached || loading || endReachedRef.current) return;
if (el.scrollHeight - el.scrollTop - el.clientHeight < rowHeight * 4) {
endReachedRef.current = true;
onEndReached();
}
}, [onEndReached, loading, rowHeight]);
const [activeColumn, setActiveColumn] = useState<string | null>(null);
// Small delay on leave so the pointer can cross the gap from the header cell
// to the portal handle without the column deactivating.
const deactivateTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const activateColumn = useCallback((key: string) => {
if (deactivateTimer.current) clearTimeout(deactivateTimer.current);
deactivateTimer.current = null;
setActiveColumn(key);
}, []);
const deactivateColumn = useCallback(() => {
if (deactivateTimer.current) clearTimeout(deactivateTimer.current);
deactivateTimer.current = setTimeout(() => setActiveColumn(null), 100);
}, []);
const rowRefs = useRef<Record<string, HTMLTableRowElement | null>>({});
const [activeRow, setActiveRow] = useState<{ id: string; index: number } | null>(
null,
);
const rowTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const activateRow = useCallback((id: string, index: number) => {
if (rowTimer.current) clearTimeout(rowTimer.current);
rowTimer.current = null;
setActiveRow({ id, index });
}, []);
const deactivateRow = useCallback(() => {
if (rowTimer.current) clearTimeout(rowTimer.current);
rowTimer.current = setTimeout(() => setActiveRow(null), 100);
}, []);
const activeRowEl = activeRow ? rowRefs.current[activeRow.id] : null;
// Real columns + checkbox; the trailing spacer adds one more in colSpans.
const leadColumns = columns.length + (selectable ? 1 : 0);
return (
<div
className={cn(
"w-full overflow-hidden border border-border bg-background text-sm",
className,
)}
>
<div
ref={scrollRef}
onScroll={handleScroll}
className="overflow-auto"
style={{ height }}
>
<table
className={cn("border-collapse", sized ? "w-max" : undefined)}
style={{
tableLayout: "fixed",
minWidth: `max(100%, ${minTableWidth}px)`,
}}
>
<colgroup>
{selectable ? <col style={{ width: CHECKBOX_WIDTH }} /> : null}
{orderedColumns.map((column) => {
const override = widths[column.key];
const width = override ? `${override}px` : column.width;
return (
<col key={column.key} style={width ? { width } : undefined} />
);
})}
{/* Empty filler owns the leftover space — no gap, content unpinned. */}
<col />
</colgroup>
<TableHeader
columns={orderedColumns}
rowHeight={rowHeight}
reduce={!!reduce}
thRefs={thRefs}
selectable={selectable}
allSelected={allSelected}
someSelected={someSelected}
onToggleAll={toggleAll}
sort={sort}
onToggleSort={toggleSort}
resizable={resizable}
onResizeStart={startResize}
onResizeMove={moveResize}
onResizeEnd={endResize}
reorderable={reorderable}
dragKey={dragKey}
dropIndex={dropIndex}
onReorderStart={startReorder}
onReorderMove={moveReorder}
onReorderEnd={endReorder}
onInsertColumn={onInsertColumn}
onDeleteColumn={onDeleteColumn}
onColumnRename={onColumnRename}
activeColumn={hasColumnMenu ? activeColumn : null}
onColumnActivate={hasColumnMenu ? activateColumn : undefined}
onColumnDeactivate={hasColumnMenu ? deactivateColumn : undefined}
/>
<tbody>
{sortedRows.length === 0 ? (
loading ? (
<SkeletonRows
count={Math.max(1, Math.ceil(height / rowHeight))}
columns={orderedColumns}
selectable={selectable}
rowHeight={rowHeight}
/>
) : (
<tr>
<td
colSpan={leadColumns + 1}
className="p-10 text-center text-muted-foreground"
>
{emptyState}
</td>
</tr>
)
) : (
<>
{paddingTop > 0 ? (
<tr aria-hidden style={{ height: paddingTop }}>
<td colSpan={leadColumns + 1} />
</tr>
) : null}
{virtualItems.map((vItem) => {
const entry = sortedRows[vItem.index];
const isSelected = selected.has(entry.id);
return (
<tr
key={entry.id}
ref={(el) => {
rowRefs.current[entry.id] = el;
}}
data-selected={isSelected}
style={{ height: rowHeight }}
onPointerEnter={
hasRowMenu
? () => activateRow(entry.id, vItem.index)
: undefined
}
onPointerLeave={hasRowMenu ? deactivateRow : undefined}
className={cn(
"border-border/60 border-b transition-colors",
"data-[selected=true]:bg-primary/5",
"hover:bg-muted/50",
)}
>
{selectable ? (
<td className="text-center">
<div className="flex items-center justify-center">
<Checkbox
checked={isSelected}
onCheckedChange={() => toggleRow(entry.id)}
aria-label={`Select row ${vItem.index + 1}`}
/>
</div>
</td>
) : null}
{orderedColumns.map((column) => (
<td
key={column.key}
className={cn(
"truncate px-4 text-foreground",
alignText(column.align),
)}
>
{!column.cell && column.editable ? (
<EditableCell
value={String(readCell(entry.row, column) ?? "")}
label={`${column.key} for row ${vItem.index + 1}`}
onChange={(next) =>
onCellEdit?.(entry.id, column.key, next)
}
/>
) : (
readCell(entry.row, column)
)}
</td>
))}
<td aria-hidden />
</tr>
);
})}
{paddingBottom > 0 ? (
<tr aria-hidden style={{ height: paddingBottom }}>
<td colSpan={leadColumns + 1} />
</tr>
) : null}
{loading ? (
<SkeletonRows
count={skeletonRows}
columns={orderedColumns}
selectable={selectable}
rowHeight={rowHeight}
/>
) : null}
</>
)}
</tbody>
</table>
</div>
{hasRowMenu && activeRow ? (
<RowHandle
rowEl={activeRowEl}
id={activeRow.id}
index={activeRow.index}
onInsertRow={onInsertRow}
onDeleteRow={onDeleteRow}
onEnter={() => activateRow(activeRow.id, activeRow.index)}
onLeave={deactivateRow}
/>
) : null}
</div>
);
}
Install
shadcn init? You are set. Theme setupInstall dependencies
npm i @tanstack/react-virtual clsx lucide-react motion tailwind-mergeAdd util files
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
// Shared touch primitives. iOS and iPadOS run their own gestures on top of the
// page — the long-press selection callout and the selection it drags in with
// it — and they win: once the platform claims a touch it cancels ours
// mid-gesture, so a press-and-hold or a drag simply dies. Surfaces that own
// their gesture have to opt out.
//
// What the two classes below cover, precisely:
// - `-webkit-touch-callout: none` stops iOS's long-press callout. WebKit-only:
// it is not a property other engines have, so it is inert everywhere else.
// - `user-select: none` stops the long-press selection on every engine,
// Android included, and stops a drag from painting a selection under the
// cursor. It is inherited, so it reaches every descendant — which is why the
// two classes differ only in whether they apply it unconditionally.
// What neither covers:
// - Chrome for Android's long-press menu on a link or an image. No CSS
// suppresses it; a gesture surface that wraps one needs its own
// `onContextMenu` with `preventDefault()`.
// - The native drag of an `<img>` or `<a>` descendant. `-webkit-user-drag` is
// not inherited and plain divs and buttons are not drag sources, so setting
// it on the surface does nothing — the child itself needs `draggable={false}`.
/**
* Classes for a surface that *is* the control: a thumb, a drum, a stage, a
* handle, a hold button. Selection is suppressed on every input, because a
* drag that highlights the control's own label is wrong on a mouse too.
* Compose with `touch-none` when the surface also owns the scroll axis — leave
* it off when the page must still scroll from there.
*/
export const TOUCH_GESTURE_CLASS = "select-none [-webkit-touch-callout:none]";
/**
* The same opt-out for a gesture surface that wraps content the consumer owns:
* a scroller, a context-menu trigger, a sheet header, a list row. Selection is
* suppressed only where the platform runs its own press gestures — a coarse
* pointer — so a mouse user can still select and copy that content. If the
* gesture itself would paint a selection under the cursor, add `select-none`
* for the duration of the gesture rather than reaching for
* `TOUCH_GESTURE_CLASS`.
*
* `pointer: coarse` describes the *primary* pointer and nothing else, so a
* hybrid machine reads it wrong in both directions: a tablet with a mouse
* plugged in keeps touch as primary and loses mouse selection, and a laptop
* with a touchscreen keeps the mouse as primary and leaves selection live
* under a finger. No media query can answer per interaction — the query is
* about the device, and the question is about the gesture in progress. The
* default stays here because it is right on the machines that are one thing or
* the other, and losing a selection is a nuisance; where the miss costs a
* *gesture* instead, the surface pairs it with `holdSelection` on the press.
*/
export const TOUCH_GESTURE_CONTENT_CLASS =
"[-webkit-touch-callout:none] pointer-coarse:select-none";
/**
* Suppress selection on `element` for as long as a gesture is running on it,
* whatever the primary pointer of the machine happens to be. Returns the
* release. Inline, so it wins over the class above and is gone again the
* moment the gesture ends.
*
* For the press gestures a native selection would otherwise steal — a
* long-press that opens a menu. Elsewhere prefer the classes: a surface that
* takes selection away for the whole session is a surface whose text nobody
* can copy.
*/
export function holdSelection(element: HTMLElement) {
element.style.setProperty("user-select", "none");
element.style.setProperty("-webkit-user-select", "none");
return () => {
element.style.removeProperty("user-select");
element.style.removeProperty("-webkit-user-select");
};
}
/**
* Pointer capture, best effort. WebKit throws `NotFoundError` when the pointer
* is already gone by the time the handler runs — routine on iOS, where the
* system can claim the touch first — and an uncaught throw takes the rest of
* the handler, the gesture included, down with it. Touch pointers carry
* implicit capture anyway, so losing it is never fatal.
*/
export function capturePointer(element: Element, pointerId: number) {
try {
element.setPointerCapture(pointerId);
} catch {
// Pointer is no longer active — implicit capture still applies on touch.
}
}
/** Release a capture taken with `capturePointer`, ignoring a stale pointer. */
export function releasePointer(element: Element, pointerId: number) {
try {
if (element.hasPointerCapture(pointerId)) {
element.releasePointerCapture(pointerId);
}
} catch {
// Capture was already dropped by the browser.
}
}
/**
* Whether this event came from a pointer that is *hovering*: not a touch, and
* not currently pressed. Which input the user is holding right now is not
* something a device capability can answer — a touchscreen laptop hovers and
* taps, and iPadOS reports a fine hovering pointer for a finger — so both
* paths stay live and each handler branches on the event it was given.
*
* A pen resting on the glass is making contact, not hovering: `buttons` is the
* tell, and it sends a pen tap down the same route a finger takes.
*
* This answers what an *enter* asks. A leave is the other half of a pair and
* has to be read against the enter that started it — `useHoverGesture` in
* `lib/hooks/use-hover-gesture` does that, and hover surfaces should use it
* rather than asking this question twice.
*/
export const isHoveringPointer = (event: {
pointerType: string;
buttons: number;
}) => event.pointerType !== "touch" && event.buttons === 0;
Copy the source code
"use client";
// beui.dev/components/motion/table
import { useVirtualizer } from "@tanstack/react-virtual";
import { useReducedMotion } from "motion/react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Checkbox } from "@/components/motion/checkbox";
import { cn } from "@/lib/utils";
import { EditableCell } from "./editable-cell";
import { RowHandle } from "./row-handle";
import { SkeletonRows } from "./skeleton-rows";
import { TableHeader } from "./table-header";
import type { HeaderCellRefs, TableProps } from "./types";
import { useColumnReorder } from "./use-column-reorder";
import { useColumnResize } from "./use-column-resize";
import { useColumnSort } from "./use-column-sort";
import { useRowSelection } from "./use-row-selection";
import { alignText, CHECKBOX_PX, CHECKBOX_WIDTH, readCell } from "./utils";
export type {
SortDirection,
SortState,
TableColumn,
TableProps,
} from "./types";
/**
* Narrowest a column of bare inputs may be floored to and still show a value:
* the cell's own `px-4` eats 32 of it.
*/
const INPUT_COLUMN_WIDTH = 120;
/** The root font size Tailwind's rem scale assumes, and the pre-measure guess. */
const DEFAULT_ROOT_FONT_SIZE = 16;
/**
* What one `rem` is worth here, in px. The default until the first client
* layout, so the server and the hydrating client emit the same floor; measured
* once after that, because a document that sets its own `html { font-size }`
* lays a rem column out against that size and a floor computed from 16 would
* fall short by the same factor.
*/
function useRootFontSize() {
const [size, setSize] = useState(DEFAULT_ROOT_FONT_SIZE);
useEffect(() => {
const measured = Number.parseFloat(
getComputedStyle(document.documentElement).fontSize,
);
if (measured > 0) setSize(measured);
}, []);
return size;
}
/**
* The absolute width a column declared, in px, or null when it declared a share
* of the remainder instead (`fr`, `%`, `auto`, `calc()`, nothing at all) — those
* are worth whatever is left over, which is not a width this can add up.
*/
function resolveColumnWidth(
width: string | undefined,
rootFontSize: number,
): number | null {
if (!width) return null;
const value = Number.parseFloat(width);
if (!Number.isFinite(value)) return null;
if (width.endsWith("px")) return value;
// rem is the other absolute length the repo writes.
if (width.endsWith("rem")) return value * rootFontSize;
return null;
}
export function Table<T>({
data,
columns,
getRowId,
selectable = false,
selectedRowIds,
defaultSelectedRowIds,
onSelectionChange,
sort: sortProp,
defaultSort = null,
onSortChange,
resizable = false,
minColumnWidth = 64,
onColumnResize,
reorderable = false,
onColumnOrderChange,
onCellEdit,
onColumnRename,
onInsertRow,
onDeleteRow,
onInsertColumn,
onDeleteColumn,
rowHeight = 48,
height = 440,
overscan = 10,
onEndReached,
loading = false,
skeletonRows = 3,
emptyState = "No data",
className,
}: TableProps<T>) {
const reduce = useReducedMotion();
const scrollRef = useRef<HTMLDivElement>(null);
const thRefs: HeaderCellRefs = useRef<
Record<string, HTMLTableCellElement | null>
>({});
const rows = useMemo(
() =>
data.map((row, index) => ({
row,
id: getRowId ? getRowId(row, index) : String(index),
})),
[data, getRowId],
);
const {
orderedColumns,
dragKey,
dropIndex,
startReorder,
moveReorder,
endReorder,
} = useColumnReorder({ columns, thRefs, onColumnOrderChange });
const { sort, sortedRows, toggleSort } = useColumnSort({
rows,
columns,
sort: sortProp,
defaultSort,
onSortChange,
});
const { widths, startResize, moveResize, endResize } = useColumnResize({
orderedColumns,
thRefs,
minColumnWidth,
onColumnResize,
});
const { selected, allSelected, someSelected, toggleAll, toggleRow } =
useRowSelection({
sortedRows,
selectedRowIds,
defaultSelectedRowIds,
onSelectionChange,
});
const virtualizer = useVirtualizer({
count: sortedRows.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => rowHeight,
overscan,
});
const virtualItems = virtualizer.getVirtualItems();
const totalSize = virtualizer.getTotalSize();
const paddingTop = virtualItems.length > 0 ? virtualItems[0].start : 0;
const paddingBottom =
virtualItems.length > 0
? totalSize - virtualItems[virtualItems.length - 1].end
: 0;
const hasRowMenu = !!(onInsertRow || onDeleteRow);
const hasColumnMenu = !!(onInsertColumn || onDeleteColumn);
// Only shrink-wrap (w-max) once every column has an explicit resized width;
// otherwise stay fill-width so a flexible column can't size to cell content.
const sized =
orderedColumns.length > 0 &&
orderedColumns.every((c) => widths[c.key] != null);
const rootFontSize = useRootFontSize();
// In a container narrower than the columns, `table-layout: fixed` shrinks
// every column toward zero instead of scrolling. Floor the table at what the
// columns actually asked for — an absolute declared width where there is one,
// and for the ones sharing the remainder whatever content they can fall back
// on — then let the viewport scroll past it.
const minTableWidth = useMemo(() => {
// A column whose cells render bare inputs has no content for the fallback
// to measure, which is exactly the column that collapses; a renamable
// header is an input too, and in a fixed layout the header row is what
// sizes the column.
const inputOnly = (column: (typeof orderedColumns)[number]) =>
Boolean(onColumnRename) || (!column.cell && Boolean(column.editable));
const total = orderedColumns.reduce((sum, column) => {
const resized = widths[column.key];
if (resized != null) return sum + resized;
const declared = resolveColumnWidth(column.width, rootFontSize);
if (declared != null) return sum + declared;
return (
sum +
(inputOnly(column)
? Math.max(minColumnWidth, INPUT_COLUMN_WIDTH)
: minColumnWidth)
);
}, selectable ? CHECKBOX_PX : 0);
return Math.round(total);
}, [
minColumnWidth,
onColumnRename,
orderedColumns,
rootFontSize,
selectable,
widths,
]);
// Infinite scroll: fire onEndReached once per near-bottom dwell, paused while
// loading; the guard resets when the load completes.
const endReachedRef = useRef(false);
useEffect(() => {
if (!loading) endReachedRef.current = false;
}, [loading]);
const handleScroll = useCallback(() => {
const el = scrollRef.current;
if (!el || !onEndReached || loading || endReachedRef.current) return;
if (el.scrollHeight - el.scrollTop - el.clientHeight < rowHeight * 4) {
endReachedRef.current = true;
onEndReached();
}
}, [onEndReached, loading, rowHeight]);
const [activeColumn, setActiveColumn] = useState<string | null>(null);
// Small delay on leave so the pointer can cross the gap from the header cell
// to the portal handle without the column deactivating.
const deactivateTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const activateColumn = useCallback((key: string) => {
if (deactivateTimer.current) clearTimeout(deactivateTimer.current);
deactivateTimer.current = null;
setActiveColumn(key);
}, []);
const deactivateColumn = useCallback(() => {
if (deactivateTimer.current) clearTimeout(deactivateTimer.current);
deactivateTimer.current = setTimeout(() => setActiveColumn(null), 100);
}, []);
const rowRefs = useRef<Record<string, HTMLTableRowElement | null>>({});
const [activeRow, setActiveRow] = useState<{ id: string; index: number } | null>(
null,
);
const rowTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const activateRow = useCallback((id: string, index: number) => {
if (rowTimer.current) clearTimeout(rowTimer.current);
rowTimer.current = null;
setActiveRow({ id, index });
}, []);
const deactivateRow = useCallback(() => {
if (rowTimer.current) clearTimeout(rowTimer.current);
rowTimer.current = setTimeout(() => setActiveRow(null), 100);
}, []);
const activeRowEl = activeRow ? rowRefs.current[activeRow.id] : null;
// Real columns + checkbox; the trailing spacer adds one more in colSpans.
const leadColumns = columns.length + (selectable ? 1 : 0);
return (
<div
className={cn(
"w-full overflow-hidden border border-border bg-background text-sm",
className,
)}
>
<div
ref={scrollRef}
onScroll={handleScroll}
className="overflow-auto"
style={{ height }}
>
<table
className={cn("border-collapse", sized ? "w-max" : undefined)}
style={{
tableLayout: "fixed",
minWidth: `max(100%, ${minTableWidth}px)`,
}}
>
<colgroup>
{selectable ? <col style={{ width: CHECKBOX_WIDTH }} /> : null}
{orderedColumns.map((column) => {
const override = widths[column.key];
const width = override ? `${override}px` : column.width;
return (
<col key={column.key} style={width ? { width } : undefined} />
);
})}
{/* Empty filler owns the leftover space — no gap, content unpinned. */}
<col />
</colgroup>
<TableHeader
columns={orderedColumns}
rowHeight={rowHeight}
reduce={!!reduce}
thRefs={thRefs}
selectable={selectable}
allSelected={allSelected}
someSelected={someSelected}
onToggleAll={toggleAll}
sort={sort}
onToggleSort={toggleSort}
resizable={resizable}
onResizeStart={startResize}
onResizeMove={moveResize}
onResizeEnd={endResize}
reorderable={reorderable}
dragKey={dragKey}
dropIndex={dropIndex}
onReorderStart={startReorder}
onReorderMove={moveReorder}
onReorderEnd={endReorder}
onInsertColumn={onInsertColumn}
onDeleteColumn={onDeleteColumn}
onColumnRename={onColumnRename}
activeColumn={hasColumnMenu ? activeColumn : null}
onColumnActivate={hasColumnMenu ? activateColumn : undefined}
onColumnDeactivate={hasColumnMenu ? deactivateColumn : undefined}
/>
<tbody>
{sortedRows.length === 0 ? (
loading ? (
<SkeletonRows
count={Math.max(1, Math.ceil(height / rowHeight))}
columns={orderedColumns}
selectable={selectable}
rowHeight={rowHeight}
/>
) : (
<tr>
<td
colSpan={leadColumns + 1}
className="p-10 text-center text-muted-foreground"
>
{emptyState}
</td>
</tr>
)
) : (
<>
{paddingTop > 0 ? (
<tr aria-hidden style={{ height: paddingTop }}>
<td colSpan={leadColumns + 1} />
</tr>
) : null}
{virtualItems.map((vItem) => {
const entry = sortedRows[vItem.index];
const isSelected = selected.has(entry.id);
return (
<tr
key={entry.id}
ref={(el) => {
rowRefs.current[entry.id] = el;
}}
data-selected={isSelected}
style={{ height: rowHeight }}
onPointerEnter={
hasRowMenu
? () => activateRow(entry.id, vItem.index)
: undefined
}
onPointerLeave={hasRowMenu ? deactivateRow : undefined}
className={cn(
"border-border/60 border-b transition-colors",
"data-[selected=true]:bg-primary/5",
"hover:bg-muted/50",
)}
>
{selectable ? (
<td className="text-center">
<div className="flex items-center justify-center">
<Checkbox
checked={isSelected}
onCheckedChange={() => toggleRow(entry.id)}
aria-label={`Select row ${vItem.index + 1}`}
/>
</div>
</td>
) : null}
{orderedColumns.map((column) => (
<td
key={column.key}
className={cn(
"truncate px-4 text-foreground",
alignText(column.align),
)}
>
{!column.cell && column.editable ? (
<EditableCell
value={String(readCell(entry.row, column) ?? "")}
label={`${column.key} for row ${vItem.index + 1}`}
onChange={(next) =>
onCellEdit?.(entry.id, column.key, next)
}
/>
) : (
readCell(entry.row, column)
)}
</td>
))}
<td aria-hidden />
</tr>
);
})}
{paddingBottom > 0 ? (
<tr aria-hidden style={{ height: paddingBottom }}>
<td colSpan={leadColumns + 1} />
</tr>
) : null}
{loading ? (
<SkeletonRows
count={skeletonRows}
columns={orderedColumns}
selectable={selectable}
rowHeight={rowHeight}
/>
) : null}
</>
)}
</tbody>
</table>
</div>
{hasRowMenu && activeRow ? (
<RowHandle
rowEl={activeRowEl}
id={activeRow.id}
index={activeRow.index}
onInsertRow={onInsertRow}
onDeleteRow={onDeleteRow}
onEnter={() => activateRow(activeRow.id, activeRow.index)}
onLeave={deactivateRow}
/>
) : null}
</div>
);
}
"use client";
export function EditableCell({
value,
label,
onChange,
}: {
value: string;
label: string;
onChange: (next: string) => void;
}) {
return (
<input
value={value}
aria-label={label}
size={1}
onChange={(e) => onChange(e.target.value)}
placeholder="Empty"
className="-mx-2 w-full min-w-0 appearance-none rounded-md border-0 bg-transparent px-2 py-1 text-foreground outline-none transition-colors placeholder:text-muted-foreground/40 focus:bg-muted focus:ring-1 focus:ring-ring"
/>
);
}
"use client";
import { ArrowDownToLine, ArrowUpToLine, MoreVertical, Trash2 } from "lucide-react";
import { useEffect } from "react";
import { createPortal } from "react-dom";
import { TableMenu } from "./table-menu";
/** The row handle, portaled so it can sit on the row's left border without the
* scroll container clipping it. Straddles the border to bridge hover. */
export function RowHandle({
rowEl,
id,
index,
onInsertRow,
onDeleteRow,
onEnter,
onLeave,
}: {
rowEl: HTMLTableRowElement | null;
id: string;
index: number;
onInsertRow?: (index: number, position: "before" | "after") => void;
onDeleteRow?: (rowId: string, index: number) => void;
onEnter: () => void;
onLeave: () => void;
}) {
useEffect(() => {
window.addEventListener("scroll", onLeave, true);
return () => window.removeEventListener("scroll", onLeave, true);
}, [onLeave]);
if (!rowEl || typeof document === "undefined") return null;
const rect = rowEl.getBoundingClientRect();
return createPortal(
<div
style={{
position: "fixed",
top: rect.top + rect.height / 2,
left: rect.left,
transform: "translate(-50%, -50%)",
zIndex: 40,
}}
onPointerEnter={onEnter}
onPointerLeave={onLeave}
>
<TableMenu
ariaLabel={`Row ${index + 1} options`}
triggerClassName="flex h-6 w-2 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-sm transition-colors hover:bg-primary/90"
trigger={<MoreVertical className="h-3 w-3" />}
items={[
...(onInsertRow
? [
{
label: "Insert before",
icon: <ArrowUpToLine />,
onSelect: () => onInsertRow(index, "before"),
},
{
label: "Insert after",
icon: <ArrowDownToLine />,
onSelect: () => onInsertRow(index, "after"),
},
]
: []),
...(onDeleteRow
? [
{
label: "Delete row",
icon: <Trash2 />,
destructive: true,
onSelect: () => onDeleteRow(id, index),
},
]
: []),
]}
/>
</div>,
document.body,
);
}
"use client";
import { cn } from "@/lib/utils";
import type { TableColumn } from "./types";
import { alignText } from "./utils";
export function SkeletonRows<T>({
count,
columns,
selectable,
rowHeight,
}: {
count: number;
columns: TableColumn<T>[];
selectable: boolean;
rowHeight: number;
}) {
return (
<>
{Array.from({ length: count }, (_, r) => (
// biome-ignore lint/suspicious/noArrayIndexKey: static placeholder rows
<tr key={r} style={{ height: rowHeight }} className="border-border/60 border-b">
{selectable ? <td /> : null}
{columns.map((column) => (
<td key={column.key} className={cn("px-4", alignText(column.align))}>
<div
className={cn(
"h-3 animate-pulse rounded-full bg-muted",
column.align === "right" ? "ml-auto w-10" : "w-2/3",
)}
/>
</td>
))}
<td aria-hidden />
</tr>
))}
</>
);
}
"use client";
import {
ArrowLeftToLine,
ArrowRightToLine,
ChevronUp,
GripVertical,
MoreHorizontal,
Trash2,
} from "lucide-react";
import { motion } from "motion/react";
import { type PointerEvent as ReactPointerEvent, useEffect } from "react";
import { createPortal } from "react-dom";
import { Checkbox } from "@/components/motion/checkbox";
import { EASE_OUT, SPRING_PRESS } from "@/lib/ease";
import { TOUCH_GESTURE_CLASS } from "@/lib/touch";
import { cn } from "@/lib/utils";
import { TableMenu } from "./table-menu";
import type {
HeaderCellRefs,
InsertPosition,
SortState,
TableColumn,
} from "./types";
import { alignFlex, alignText, COLUMN_ACTIVE_SHADOW } from "./utils";
export interface TableHeaderProps<T> {
columns: TableColumn<T>[];
rowHeight: number;
reduce: boolean;
thRefs: HeaderCellRefs;
selectable: boolean;
allSelected: boolean;
someSelected: boolean;
onToggleAll: () => void;
sort: SortState | null;
onToggleSort: (key: string) => void;
resizable: boolean;
onResizeStart: (key: string, e: ReactPointerEvent) => void;
onResizeMove: (e: ReactPointerEvent) => void;
onResizeEnd: (e: ReactPointerEvent) => void;
reorderable: boolean;
dragKey: string | null;
dropIndex: number | null;
onReorderStart: (key: string, e: ReactPointerEvent) => void;
onReorderMove: (e: ReactPointerEvent) => void;
onReorderEnd: (e: ReactPointerEvent) => void;
onInsertColumn?: (index: number, position: InsertPosition) => void;
onDeleteColumn?: (columnKey: string, index: number) => void;
onColumnRename?: (columnKey: string, value: string) => void;
activeColumn: string | null;
onColumnActivate?: (key: string) => void;
onColumnDeactivate?: () => void;
}
/** Column insert / delete menu items shared by the header cell and the portal handle. */
function columnMenuItems<T>(
column: TableColumn<T>,
index: number,
onInsertColumn?: (index: number, position: InsertPosition) => void,
onDeleteColumn?: (columnKey: string, index: number) => void,
) {
return [
...(onInsertColumn
? [
{
label: "Insert before",
icon: <ArrowLeftToLine />,
onSelect: () => onInsertColumn(index, "before"),
},
{
label: "Insert after",
icon: <ArrowRightToLine />,
onSelect: () => onInsertColumn(index, "after"),
},
]
: []),
...(onDeleteColumn
? [
{
label: "Delete column",
icon: <Trash2 />,
destructive: true,
onSelect: () => onDeleteColumn(column.key, index),
},
]
: []),
];
}
/** The ellipse handle, portaled so it can sit on the column's top border without
* the scroll container clipping it. Straddles the border to bridge hover. */
function ColumnHandle<T>({
column,
index,
thRefs,
onInsertColumn,
onDeleteColumn,
onEnter,
onLeave,
}: {
column: TableColumn<T>;
index: number;
thRefs: HeaderCellRefs;
onInsertColumn?: (index: number, position: InsertPosition) => void;
onDeleteColumn?: (columnKey: string, index: number) => void;
onEnter: () => void;
onLeave: () => void;
}) {
useEffect(() => {
window.addEventListener("scroll", onLeave, true);
return () => window.removeEventListener("scroll", onLeave, true);
}, [onLeave]);
const el = thRefs.current[column.key];
if (!el || typeof document === "undefined") return null;
const rect = el.getBoundingClientRect();
return createPortal(
<div
style={{
position: "fixed",
top: rect.top,
left: rect.left + rect.width / 2,
transform: "translate(-50%, -50%)",
zIndex: 40,
}}
onPointerEnter={onEnter}
onPointerLeave={onLeave}
>
<TableMenu
ariaLabel={`${column.key} column options`}
triggerClassName="flex h-2 w-6 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-sm transition-colors hover:bg-primary/90"
trigger={<MoreHorizontal className="h-3 w-3" />}
items={columnMenuItems(column, index, onInsertColumn, onDeleteColumn)}
/>
</div>,
document.body,
);
}
export function TableHeader<T>({
columns,
rowHeight,
reduce,
thRefs,
selectable,
allSelected,
someSelected,
onToggleAll,
sort,
onToggleSort,
resizable,
onResizeStart,
onResizeMove,
onResizeEnd,
reorderable,
dragKey,
dropIndex,
onReorderStart,
onReorderMove,
onReorderEnd,
onInsertColumn,
onDeleteColumn,
onColumnRename,
activeColumn,
onColumnActivate,
onColumnDeactivate,
}: TableHeaderProps<T>) {
const hasColumnMenu = !!(onInsertColumn || onDeleteColumn);
const activeIndex = columns.findIndex((c) => c.key === activeColumn);
return (
<>
{hasColumnMenu && activeColumn && activeIndex >= 0 ? (
<ColumnHandle
column={columns[activeIndex]}
index={activeIndex}
thRefs={thRefs}
onInsertColumn={onInsertColumn}
onDeleteColumn={onDeleteColumn}
onEnter={() => onColumnActivate?.(activeColumn)}
onLeave={() => onColumnDeactivate?.()}
/>
) : null}
<thead>
<tr style={{ height: rowHeight }}>
{selectable ? (
<th className="sticky top-0 z-10 border-border border-b bg-muted">
<div className="flex items-center justify-center">
<Checkbox
checked={allSelected}
indeterminate={!allSelected && someSelected}
onCheckedChange={onToggleAll}
aria-label="Select all rows"
className="size-6"
/>
</div>
</th>
) : null}
{columns.map((column, index) => {
const active = sort?.key === column.key;
const isDragging = dragKey === column.key;
const isActive = activeColumn === column.key;
return (
<th
key={column.key}
ref={(el) => {
thRefs.current[column.key] = el;
}}
onPointerEnter={() => onColumnActivate?.(column.key)}
onPointerLeave={() => onColumnDeactivate?.()}
style={isActive ? { boxShadow: COLUMN_ACTIVE_SHADOW } : undefined}
aria-sort={
active
? sort?.direction === "asc"
? "ascending"
: "descending"
: undefined
}
data-drop={dragKey ? dropIndex === index : undefined}
data-dropend={
dragKey
? dropIndex === columns.length && index === columns.length - 1
: undefined
}
className={cn(
"group sticky top-0 z-10 border-border border-b bg-muted p-0 font-medium text-muted-foreground",
"data-[drop=true]:before:absolute data-[drop=true]:before:inset-y-0 data-[drop=true]:before:left-0 data-[drop=true]:before:w-0.5 data-[drop=true]:before:bg-primary",
"data-[dropend=true]:after:absolute data-[dropend=true]:after:inset-y-0 data-[dropend=true]:after:right-0 data-[dropend=true]:after:w-0.5 data-[dropend=true]:after:bg-primary",
)}
>
<motion.div
className={cn(
"flex h-full items-center",
alignFlex(column.align),
)}
style={{ height: rowHeight }}
animate={
reduce
? { opacity: isDragging ? 0.5 : 1 }
: {
scale: isDragging ? 1.04 : 1,
opacity: isDragging ? 0.5 : 1,
}
}
transition={SPRING_PRESS}
>
{reorderable ? (
<button
type="button"
aria-label={`Reorder ${column.key} column`}
onPointerDown={(e) => onReorderStart(column.key, e)}
onPointerMove={onReorderMove}
onPointerUp={onReorderEnd}
className={cn(
"flex h-full w-6 cursor-grab touch-none items-center justify-center text-muted-foreground/60 transition-colors hover:text-foreground active:cursor-grabbing",
// The grip owns the whole press, so iOS must not open its
// callout out of the same one and cancel the drag.
TOUCH_GESTURE_CLASS,
)}
>
<GripVertical className="h-3.5 w-3.5" />
</button>
) : null}
{column.sortable ? (
<button
type="button"
onClick={() => onToggleSort(column.key)}
className={cn(
"flex h-full min-w-0 flex-1 select-none items-center gap-1 px-4 transition-colors hover:text-foreground",
alignFlex(column.align),
active && "text-foreground",
)}
>
<span className="truncate">{column.header}</span>
<motion.span
aria-hidden
className="inline-flex shrink-0"
animate={{
rotate: active && sort?.direction === "desc" ? 180 : 0,
opacity: active ? 1 : 0.35,
}}
transition={
reduce
? { duration: 0 }
: { duration: 0.18, ease: EASE_OUT }
}
>
<ChevronUp className="h-3.5 w-3.5" />
</motion.span>
</button>
) : onColumnRename ? (
<input
value={
typeof column.header === "string" ? column.header : ""
}
aria-label={`Rename ${column.key} column`}
size={1}
onChange={(e) =>
onColumnRename(column.key, e.target.value)
}
className={cn(
"min-w-0 flex-1 truncate appearance-none rounded-md border-0 bg-transparent px-4 font-medium text-muted-foreground outline-none transition-colors focus:bg-muted focus:text-foreground",
alignText(column.align),
)}
/>
) : (
<span
className={cn(
"min-w-0 flex-1 truncate px-4",
alignText(column.align),
)}
>
{column.header}
</span>
)}
</motion.div>
{resizable ? (
<button
type="button"
aria-label={`Resize ${column.key} column`}
tabIndex={-1}
onPointerDown={(e) => onResizeStart(column.key, e)}
onPointerMove={onResizeMove}
onPointerUp={onResizeEnd}
className={cn(
"absolute top-0 right-0 h-full w-1.5 cursor-col-resize touch-none bg-transparent transition-colors hover:bg-primary/40",
// Same for the resize drag: the handle drives it end to end.
TOUCH_GESTURE_CLASS,
)}
/>
) : null}
</th>
);
})}
<th
aria-hidden
className="sticky top-0 z-10 border-border border-b bg-muted"
/>
</tr>
</thead>
</>
);
}
import type { ReactNode } from "react";
export type SortDirection = "asc" | "desc";
export type SortState = {
key: string;
direction: SortDirection;
};
export type TableColumn<T> = {
/** Stable key; also the default object property read for the cell + sort value. */
key: string;
/** Header content. */
header: ReactNode;
/** Allow clicking the header to sort by this column. */
sortable?: boolean;
/** Cell text alignment. */
align?: "left" | "center" | "right";
/** Column width as a CSS length, e.g. "160px" or "20%". Omit to share remaining space equally. */
width?: string;
/** Custom cell renderer. Falls back to `row[key]`. */
cell?: (row: T) => ReactNode;
/** Render an inline text input for this column's cells (ignored when `cell` is set). */
editable?: boolean;
/** Value used for sorting. Falls back to `row[key]`. */
sortValue?: (row: T) => string | number;
};
export type InsertPosition = "before" | "after";
export interface TableProps<T> {
data: T[];
columns: TableColumn<T>[];
/** Stable id per row, required for correct selection across sorts. Defaults to row index. */
getRowId?: (row: T, index: number) => string;
/** Render a leading checkbox column with select-all in the header. */
selectable?: boolean;
selectedRowIds?: string[];
defaultSelectedRowIds?: string[];
onSelectionChange?: (ids: string[]) => void;
sort?: SortState | null;
defaultSort?: SortState | null;
onSortChange?: (sort: SortState | null) => void;
/** Allow dragging the right edge of a header to resize that column. */
resizable?: boolean;
/** Minimum column width in px when resizing. */
minColumnWidth?: number;
onColumnResize?: (key: string, width: number) => void;
/** Allow dragging a header grip to reorder columns. */
reorderable?: boolean;
onColumnOrderChange?: (keys: string[]) => void;
/** Called when an `editable` cell changes. */
onCellEdit?: (rowId: string, columnKey: string, value: string) => void;
/** When set, non-sortable headers become editable inputs for the column name. */
onColumnRename?: (columnKey: string, value: string) => void;
/** Enables the row menu (Insert before / after). Receives the target index. */
onInsertRow?: (index: number, position: InsertPosition) => void;
/** Enables Delete in the row menu. */
onDeleteRow?: (rowId: string, index: number) => void;
/** Enables the column menu (Insert before / after). Receives the target column index. */
onInsertColumn?: (index: number, position: InsertPosition) => void;
/** Enables Delete in the column menu. */
onDeleteColumn?: (columnKey: string, index: number) => void;
/** Fixed row height in px — required for virtualization. */
rowHeight?: number;
/** Scroll viewport height in px. */
height?: number;
/** Rows rendered above/below the viewport. */
overscan?: number;
/** Fires when the viewport scrolls near the bottom — load the next page. */
onEndReached?: () => void;
/** Currently fetching — shows skeleton rows and pauses `onEndReached`. */
loading?: boolean;
/** How many skeleton rows to show while loading more (default 3). */
skeletonRows?: number;
emptyState?: ReactNode;
className?: string;
}
/** A data row paired with its stable id. */
export type TableRow<T> = { row: T; id: string };
/** Ref map from column key to its header cell, shared across the resize/reorder hooks. */
export type HeaderCellRefs = {
current: Record<string, HTMLTableCellElement | null>;
};
import {
type PointerEvent as ReactPointerEvent,
useCallback,
useMemo,
useState,
} from "react";
import { capturePointer, releasePointer } from "@/lib/touch";
import type { HeaderCellRefs, TableColumn } from "./types";
export function useColumnReorder<T>({
columns,
thRefs,
onColumnOrderChange,
}: {
columns: TableColumn<T>[];
thRefs: HeaderCellRefs;
onColumnOrderChange?: (keys: string[]) => void;
}) {
const [order, setOrder] = useState<string[]>(() =>
columns.map((c) => c.key),
);
const [dragKey, setDragKey] = useState<string | null>(null);
const [dropIndex, setDropIndex] = useState<number | null>(null);
// Apply the current order, tolerating columns added/removed at runtime. New
// columns are placed at their position in `columns` (after their left
// neighbor), not appended — so an inserted column lands where it was added.
const orderedColumns = useMemo(() => {
const byKey = new Map(columns.map((c) => [c.key, c]));
const resultKeys = order.filter((k) => byKey.has(k));
const present = new Set(resultKeys);
columns.forEach((column, i) => {
if (present.has(column.key)) return;
let at = resultKeys.length;
if (i === 0) {
at = 0;
} else {
const idx = resultKeys.indexOf(columns[i - 1].key);
at = idx === -1 ? i : idx + 1;
}
resultKeys.splice(at, 0, column.key);
present.add(column.key);
});
return resultKeys
.map((k) => byKey.get(k))
.filter((c): c is TableColumn<T> => c !== undefined);
}, [order, columns]);
const dropIndexFor = useCallback(
(clientX: number) => {
for (let i = 0; i < orderedColumns.length; i++) {
const rect =
thRefs.current[orderedColumns[i].key]?.getBoundingClientRect();
if (rect && clientX < rect.left + rect.width / 2) return i;
}
return orderedColumns.length;
},
[orderedColumns, thRefs],
);
const startReorder = useCallback((key: string, e: ReactPointerEvent) => {
e.preventDefault();
e.stopPropagation();
setDragKey(key);
capturePointer(e.currentTarget, e.pointerId);
}, []);
const moveReorder = useCallback(
(e: ReactPointerEvent) => {
if (!dragKey) return;
setDropIndex(dropIndexFor(e.clientX));
},
[dragKey, dropIndexFor],
);
const endReorder = useCallback(
(e: ReactPointerEvent) => {
releasePointer(e.currentTarget, e.pointerId);
if (dragKey && dropIndex !== null) {
const keys = orderedColumns.map((c) => c.key);
const from = keys.indexOf(dragKey);
if (from !== -1) {
const without = keys.filter((_, i) => i !== from);
let to = dropIndex;
if (from < to) to--;
without.splice(to, 0, dragKey);
setOrder(without);
onColumnOrderChange?.(without);
}
}
setDragKey(null);
setDropIndex(null);
},
[dragKey, dropIndex, orderedColumns, onColumnOrderChange],
);
return {
orderedColumns,
dragKey,
dropIndex,
startReorder,
moveReorder,
endReorder,
};
}
import {
type PointerEvent as ReactPointerEvent,
useCallback,
useRef,
useState,
} from "react";
import { capturePointer, releasePointer } from "@/lib/touch";
import type { HeaderCellRefs, TableColumn } from "./types";
export function useColumnResize<T>({
orderedColumns,
thRefs,
minColumnWidth,
onColumnResize,
}: {
orderedColumns: TableColumn<T>[];
thRefs: HeaderCellRefs;
minColumnWidth: number;
onColumnResize?: (key: string, width: number) => void;
}) {
const resizeRef = useRef<{
key: string;
startX: number;
startWidth: number;
} | null>(null);
const [widths, setWidths] = useState<Record<string, number>>({});
const startResize = useCallback(
(key: string, e: ReactPointerEvent) => {
e.preventDefault();
e.stopPropagation();
// Freeze every column to its current pixel width so resizing one only
// moves the trailing spacer, never the other columns.
const snapshot = { ...widths };
for (const column of orderedColumns) {
if (snapshot[column.key] == null) {
const measured = thRefs.current[column.key]?.getBoundingClientRect()
.width;
snapshot[column.key] = measured
? Math.round(measured)
: minColumnWidth;
}
}
resizeRef.current = {
key,
startX: e.clientX,
startWidth: snapshot[key],
};
setWidths(snapshot);
capturePointer(e.currentTarget, e.pointerId);
},
[minColumnWidth, orderedColumns, thRefs, widths],
);
const moveResize = useCallback(
(e: ReactPointerEvent) => {
const state = resizeRef.current;
if (!state) return;
const width = Math.max(
minColumnWidth,
state.startWidth + (e.clientX - state.startX),
);
setWidths((prev) => ({ ...prev, [state.key]: width }));
},
[minColumnWidth],
);
const endResize = useCallback(
(e: ReactPointerEvent) => {
const state = resizeRef.current;
resizeRef.current = null;
releasePointer(e.currentTarget, e.pointerId);
if (state) {
onColumnResize?.(state.key, widths[state.key] ?? state.startWidth);
}
},
[onColumnResize, widths],
);
return { widths, startResize, moveResize, endResize };
}
import { useCallback, useMemo, useState } from "react";
import type { SortState, TableColumn, TableRow } from "./types";
import { readSortValue } from "./utils";
export function useColumnSort<T>({
rows,
columns,
sort: sortProp,
defaultSort = null,
onSortChange,
}: {
rows: TableRow<T>[];
columns: TableColumn<T>[];
sort?: SortState | null;
defaultSort?: SortState | null;
onSortChange?: (sort: SortState | null) => void;
}) {
const [internalSort, setInternalSort] = useState<SortState | null>(
defaultSort,
);
const sort = sortProp !== undefined ? sortProp : internalSort;
const commit = useCallback(
(next: SortState | null) => {
if (sortProp === undefined) setInternalSort(next);
onSortChange?.(next);
},
[sortProp, onSortChange],
);
const toggleSort = useCallback(
(key: string) => {
if (!sort || sort.key !== key) {
commit({ key, direction: "asc" });
} else if (sort.direction === "asc") {
commit({ key, direction: "desc" });
} else {
commit(null);
}
},
[sort, commit],
);
const sortedRows = useMemo(() => {
if (!sort) return rows;
const column = columns.find((c) => c.key === sort.key);
if (!column) return rows;
const copy = [...rows];
copy.sort((a, b) => {
const av = readSortValue(a.row, column);
const bv = readSortValue(b.row, column);
let cmp: number;
if (typeof av === "number" && typeof bv === "number") {
cmp = av - bv;
} else {
cmp = String(av).localeCompare(String(bv));
}
return sort.direction === "asc" ? cmp : -cmp;
});
return copy;
}, [rows, sort, columns]);
return { sort, sortedRows, toggleSort };
}
import { useCallback, useMemo, useState } from "react";
import type { TableRow } from "./types";
export function useRowSelection<T>({
sortedRows,
selectedRowIds,
defaultSelectedRowIds,
onSelectionChange,
}: {
sortedRows: TableRow<T>[];
selectedRowIds?: string[];
defaultSelectedRowIds?: string[];
onSelectionChange?: (ids: string[]) => void;
}) {
const [internalSelected, setInternalSelected] = useState<Set<string>>(
() => new Set(defaultSelectedRowIds),
);
const selected = useMemo(
() =>
selectedRowIds !== undefined
? new Set(selectedRowIds)
: internalSelected,
[selectedRowIds, internalSelected],
);
const commit = useCallback(
(next: Set<string>) => {
if (selectedRowIds === undefined) setInternalSelected(next);
onSelectionChange?.([...next]);
},
[selectedRowIds, onSelectionChange],
);
const allSelected =
sortedRows.length > 0 && sortedRows.every((r) => selected.has(r.id));
const someSelected = sortedRows.some((r) => selected.has(r.id));
const toggleAll = useCallback(() => {
const next = new Set(selected);
if (allSelected) {
for (const r of sortedRows) next.delete(r.id);
} else {
for (const r of sortedRows) next.add(r.id);
}
commit(next);
}, [allSelected, sortedRows, selected, commit]);
const toggleRow = useCallback(
(id: string) => {
const next = new Set(selected);
if (next.has(id)) next.delete(id);
else next.add(id);
commit(next);
},
[selected, commit],
);
return { selected, allSelected, someSelected, toggleAll, toggleRow };
}
import type { ReactNode } from "react";
import type { TableColumn } from "./types";
export const CHECKBOX_PX = 48;
export const CHECKBOX_WIDTH = `${CHECKBOX_PX}px`;
/** Highlights the top edge of the active column's header cell. */
export const COLUMN_ACTIVE_SHADOW = "inset 0 1px 0 var(--color-primary)";
export function alignFlex(align: TableColumn<unknown>["align"]) {
if (align === "right") return "justify-end";
if (align === "center") return "justify-center";
return "justify-start";
}
export function alignText(align: TableColumn<unknown>["align"]) {
if (align === "right") return "text-right";
if (align === "center") return "text-center";
return "text-left";
}
export function readCell<T>(row: T, column: TableColumn<T>): ReactNode {
if (column.cell) return column.cell(row);
return (row as Record<string, ReactNode>)[column.key];
}
export function readSortValue<T>(
row: T,
column: TableColumn<T>,
): string | number {
if (column.sortValue) return column.sortValue(row);
return (row as Record<string, string | number>)[column.key];
}
"use client";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useId } from "react";
import { EASE_OUT, SPRING_PRESS } from "@/lib/ease";
import { cn } from "@/lib/utils";
const CHECK_PATH = "M5 13l4 4L19 7";
const INDETERMINATE_PATH = "M6 12h12";
export interface CheckboxProps {
checked: boolean;
onCheckedChange: (checked: boolean) => void;
disabled?: boolean;
indeterminate?: boolean;
label?: string;
className?: string;
id?: string;
"aria-label"?: string;
/** Associates an external message (e.g. a form error) with the control. */
"aria-describedby"?: string;
}
export function Checkbox({
checked,
onCheckedChange,
disabled,
indeterminate,
label,
className,
id: idProp,
"aria-label": ariaLabel,
"aria-describedby": ariaDescribedBy,
}: CheckboxProps) {
const autoId = useId();
const id = idProp ?? autoId;
const reduce = useReducedMotion();
const showMark = checked || indeterminate;
const path = indeterminate ? INDETERMINATE_PATH : CHECK_PATH;
return (
<label
htmlFor={id}
className={cn(
"inline-flex items-center gap-3",
disabled ? "cursor-not-allowed" : "cursor-pointer",
className,
)}
>
<motion.button
id={id}
type="button"
role="checkbox"
aria-checked={indeterminate ? "mixed" : checked}
aria-label={ariaLabel}
aria-describedby={ariaDescribedBy}
disabled={disabled}
onClick={() => !disabled && onCheckedChange(!checked)}
whileTap={reduce || disabled ? undefined : { scale: 0.92 }}
transition={SPRING_PRESS}
data-state={
checked ? "checked" : indeterminate ? "indeterminate" : "unchecked"
}
className={cn(
"inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-md border-2 outline-none transition-colors duration-200",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"disabled:cursor-not-allowed disabled:opacity-60",
showMark
? "border-primary bg-primary text-primary-foreground"
: "border-muted-foreground/50 bg-background hover:border-muted-foreground",
)}
>
<AnimatePresence initial={false}>
{showMark ? (
<motion.svg
key={indeterminate ? "indeterminate" : "checked"}
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={3}
strokeLinecap="round"
strokeLinejoin="round"
initial={reduce ? { opacity: 1 } : { opacity: 0, scale: 0.5 }}
animate={reduce ? { opacity: 1 } : { opacity: 1, scale: 1 }}
exit={
reduce
? { opacity: 0 }
: { opacity: 0, scale: 0.5, filter: "blur(4px)" }
}
transition={
reduce ? { duration: 0 } : { duration: 0.16, ease: EASE_OUT }
}
aria-hidden
>
<title>{indeterminate ? "Partially selected" : "Selected"}</title>
<motion.path
d={path}
initial={reduce ? { pathLength: 1 } : { pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={
reduce
? { duration: 0 }
: {
duration: indeterminate ? 0.2 : 0.3,
ease: EASE_OUT,
delay: 0.04,
}
}
/>
</motion.svg>
) : null}
</AnimatePresence>
</motion.button>
{label ? (
<span className={cn("select-none text-sm text-foreground", disabled && "opacity-60")}>
{label}
</span>
) : null}
</label>
);
}
"use client";
import { motion, useReducedMotion } from "motion/react";
import { type ReactNode, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { SPRING_PANEL } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type TableMenuItem = {
label: string;
icon?: ReactNode;
onSelect: () => void;
destructive?: boolean;
};
const MENU_WIDTH = 188;
export function TableMenu({
items,
ariaLabel,
trigger,
triggerClassName,
}: {
items: TableMenuItem[];
ariaLabel: string;
trigger: ReactNode;
triggerClassName?: string;
}) {
const reduce = useReducedMotion();
const triggerRef = useRef<HTMLButtonElement>(null);
const [coords, setCoords] = useState<{ top: number; left: number } | null>(
null,
);
const open = coords !== null;
useEffect(() => {
if (!open) return;
const close = () => setCoords(null);
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") close();
};
// Close on any scroll (the trigger moves) or resize; fixed coords go stale.
window.addEventListener("scroll", close, true);
window.addEventListener("resize", close);
window.addEventListener("keydown", onKey);
return () => {
window.removeEventListener("scroll", close, true);
window.removeEventListener("resize", close);
window.removeEventListener("keydown", onKey);
};
}, [open]);
const toggle = () => {
if (open) {
setCoords(null);
return;
}
const el = triggerRef.current;
if (!el) return;
const r = el.getBoundingClientRect();
setCoords({
top: r.bottom + 4,
left: Math.max(8, r.right - MENU_WIDTH),
});
};
return (
<>
<button
ref={triggerRef}
type="button"
aria-label={ariaLabel}
aria-haspopup="menu"
aria-expanded={open}
onClick={(e) => {
e.stopPropagation();
toggle();
}}
className={triggerClassName}
>
{trigger}
</button>
{open && typeof document !== "undefined"
? createPortal(
<>
<div
className="fixed inset-0 z-40"
onPointerDown={() => setCoords(null)}
/>
<motion.div
role="menu"
className="fixed z-50 overflow-hidden rounded-xl border border-border bg-background p-1 shadow-xl"
style={{ top: coords.top, left: coords.left, width: MENU_WIDTH }}
initial={
reduce ? { opacity: 0 } : { opacity: 0, scale: 0.96, y: -4 }
}
animate={
reduce ? { opacity: 1 } : { opacity: 1, scale: 1, y: 0 }
}
transition={reduce ? { duration: 0 } : SPRING_PANEL}
>
{items.map((item) => (
<button
key={item.label}
type="button"
role="menuitem"
onClick={() => {
setCoords(null);
item.onSelect();
}}
className={cn(
"flex w-full items-center gap-2.5 rounded-lg px-2.5 py-1.5 text-left text-sm transition-colors [&_svg]:h-4 [&_svg]:w-4",
item.destructive
? "text-rose-500 hover:bg-rose-500/10"
: "text-foreground hover:bg-muted",
)}
>
{item.icon}
{item.label}
</button>
))}
</motion.div>
</>,
document.body,
)
: null}
</>
);
}
Editable Table
index.tsxEdit cells inline and insert or delete rows and columns via border handles; the table re-renders from the updated data and column defs.
Click a cell to edit. Use the column and row handles to insert or delete.
"use client";
import { useCallback, useMemo, useRef, useState } from "react";
import { Switch } from "@/components/motion/switch";
import { Table, type TableColumn } from "@/components/motion/table";
type Row = { id: string; [key: string]: string };
const INITIAL_ROWS: Row[] = [
{ id: "r1", name: "Ava Cole", role: "Owner", team: "Design" },
{ id: "r2", name: "Leo Frost", role: "Admin", team: "Growth" },
{ id: "r3", name: "Mia Vale", role: "Member", team: "Design" },
{ id: "r4", name: "Kai Reyes", role: "Member", team: "Platform" },
];
export function TableEditablePreview() {
const [rows, setRows] = useState<Row[]>(INITIAL_ROWS);
const [keys, setKeys] = useState<string[]>(["name", "role", "team"]);
const [labels, setLabels] = useState<Record<string, string>>({
name: "Name",
role: "Role",
team: "Team",
});
const nextRow = useRef(5);
const nextCol = useRef(1);
const [editable, setEditable] = useState(true);
const onCellEdit = useCallback(
(rowId: string, key: string, value: string) => {
setRows((prev) =>
prev.map((row) => (row.id === rowId ? { ...row, [key]: value } : row)),
);
},
[],
);
const onInsertRow = useCallback(
(index: number, position: "before" | "after") => {
const at = position === "after" ? index + 1 : index;
setRows((prev) => {
const next = [...prev];
next.splice(at, 0, { id: `r${nextRow.current}` });
return next;
});
nextRow.current += 1;
},
[],
);
const onDeleteRow = useCallback((rowId: string) => {
setRows((prev) => prev.filter((row) => row.id !== rowId));
}, []);
const onInsertColumn = useCallback(
(index: number, position: "before" | "after") => {
const key = `field${nextCol.current}`;
const at = position === "after" ? index + 1 : index;
setLabels((prev) => ({ ...prev, [key]: `Field ${nextCol.current}` }));
setKeys((prev) => {
const next = [...prev];
next.splice(at, 0, key);
return next;
});
setRows((prev) => prev.map((row) => ({ ...row, [key]: "" })));
nextCol.current += 1;
},
[],
);
const onColumnRename = useCallback((key: string, value: string) => {
setLabels((prev) => ({ ...prev, [key]: value }));
}, []);
const onDeleteColumn = useCallback((key: string) => {
setKeys((prev) => prev.filter((k) => k !== key));
setRows((prev) =>
prev.map((row) => {
const next = { ...row };
delete next[key];
return next;
}),
);
}, []);
const columns = useMemo<TableColumn<Row>[]>(
() =>
keys.map((key, i) => ({
key,
header: labels[key] ?? key,
editable,
width: i === 0 ? undefined : "180px",
})),
[keys, labels, editable],
);
const bodyHeight = Math.min(Math.max(rows.length, 1), 6) * 48;
return (
<div className="flex w-full flex-col gap-3 p-4">
<div className="flex items-center justify-between">
<p className="text-muted-foreground text-xs">
{editable
? "Click a cell to edit. Use the column and row handles to insert or delete."
: "Read-only."}
</p>
<Switch
checked={editable}
onCheckedChange={setEditable}
label="Editable"
/>
</div>
<Table
data={rows}
columns={columns}
getRowId={(row) => row.id}
rowHeight={48}
height={bodyHeight}
onCellEdit={editable ? onCellEdit : undefined}
onColumnRename={editable ? onColumnRename : undefined}
onInsertRow={editable ? onInsertRow : undefined}
onDeleteRow={editable ? onDeleteRow : undefined}
onInsertColumn={editable ? onInsertColumn : undefined}
onDeleteColumn={editable ? onDeleteColumn : undefined}
emptyState={
<button
type="button"
onClick={() => onInsertRow(0, "before")}
className="rounded-full border border-border px-3 py-1.5 font-medium text-foreground text-xs transition-colors hover:bg-muted"
>
Insert first row
</button>
}
/>
</div>
);
}
"use client";
// beui.dev/components/motion/table
import { useVirtualizer } from "@tanstack/react-virtual";
import { useReducedMotion } from "motion/react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Checkbox } from "@/components/motion/checkbox";
import { cn } from "@/lib/utils";
import { EditableCell } from "./editable-cell";
import { RowHandle } from "./row-handle";
import { SkeletonRows } from "./skeleton-rows";
import { TableHeader } from "./table-header";
import type { HeaderCellRefs, TableProps } from "./types";
import { useColumnReorder } from "./use-column-reorder";
import { useColumnResize } from "./use-column-resize";
import { useColumnSort } from "./use-column-sort";
import { useRowSelection } from "./use-row-selection";
import { alignText, CHECKBOX_PX, CHECKBOX_WIDTH, readCell } from "./utils";
export type {
SortDirection,
SortState,
TableColumn,
TableProps,
} from "./types";
/**
* Narrowest a column of bare inputs may be floored to and still show a value:
* the cell's own `px-4` eats 32 of it.
*/
const INPUT_COLUMN_WIDTH = 120;
/** The root font size Tailwind's rem scale assumes, and the pre-measure guess. */
const DEFAULT_ROOT_FONT_SIZE = 16;
/**
* What one `rem` is worth here, in px. The default until the first client
* layout, so the server and the hydrating client emit the same floor; measured
* once after that, because a document that sets its own `html { font-size }`
* lays a rem column out against that size and a floor computed from 16 would
* fall short by the same factor.
*/
function useRootFontSize() {
const [size, setSize] = useState(DEFAULT_ROOT_FONT_SIZE);
useEffect(() => {
const measured = Number.parseFloat(
getComputedStyle(document.documentElement).fontSize,
);
if (measured > 0) setSize(measured);
}, []);
return size;
}
/**
* The absolute width a column declared, in px, or null when it declared a share
* of the remainder instead (`fr`, `%`, `auto`, `calc()`, nothing at all) — those
* are worth whatever is left over, which is not a width this can add up.
*/
function resolveColumnWidth(
width: string | undefined,
rootFontSize: number,
): number | null {
if (!width) return null;
const value = Number.parseFloat(width);
if (!Number.isFinite(value)) return null;
if (width.endsWith("px")) return value;
// rem is the other absolute length the repo writes.
if (width.endsWith("rem")) return value * rootFontSize;
return null;
}
export function Table<T>({
data,
columns,
getRowId,
selectable = false,
selectedRowIds,
defaultSelectedRowIds,
onSelectionChange,
sort: sortProp,
defaultSort = null,
onSortChange,
resizable = false,
minColumnWidth = 64,
onColumnResize,
reorderable = false,
onColumnOrderChange,
onCellEdit,
onColumnRename,
onInsertRow,
onDeleteRow,
onInsertColumn,
onDeleteColumn,
rowHeight = 48,
height = 440,
overscan = 10,
onEndReached,
loading = false,
skeletonRows = 3,
emptyState = "No data",
className,
}: TableProps<T>) {
const reduce = useReducedMotion();
const scrollRef = useRef<HTMLDivElement>(null);
const thRefs: HeaderCellRefs = useRef<
Record<string, HTMLTableCellElement | null>
>({});
const rows = useMemo(
() =>
data.map((row, index) => ({
row,
id: getRowId ? getRowId(row, index) : String(index),
})),
[data, getRowId],
);
const {
orderedColumns,
dragKey,
dropIndex,
startReorder,
moveReorder,
endReorder,
} = useColumnReorder({ columns, thRefs, onColumnOrderChange });
const { sort, sortedRows, toggleSort } = useColumnSort({
rows,
columns,
sort: sortProp,
defaultSort,
onSortChange,
});
const { widths, startResize, moveResize, endResize } = useColumnResize({
orderedColumns,
thRefs,
minColumnWidth,
onColumnResize,
});
const { selected, allSelected, someSelected, toggleAll, toggleRow } =
useRowSelection({
sortedRows,
selectedRowIds,
defaultSelectedRowIds,
onSelectionChange,
});
const virtualizer = useVirtualizer({
count: sortedRows.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => rowHeight,
overscan,
});
const virtualItems = virtualizer.getVirtualItems();
const totalSize = virtualizer.getTotalSize();
const paddingTop = virtualItems.length > 0 ? virtualItems[0].start : 0;
const paddingBottom =
virtualItems.length > 0
? totalSize - virtualItems[virtualItems.length - 1].end
: 0;
const hasRowMenu = !!(onInsertRow || onDeleteRow);
const hasColumnMenu = !!(onInsertColumn || onDeleteColumn);
// Only shrink-wrap (w-max) once every column has an explicit resized width;
// otherwise stay fill-width so a flexible column can't size to cell content.
const sized =
orderedColumns.length > 0 &&
orderedColumns.every((c) => widths[c.key] != null);
const rootFontSize = useRootFontSize();
// In a container narrower than the columns, `table-layout: fixed` shrinks
// every column toward zero instead of scrolling. Floor the table at what the
// columns actually asked for — an absolute declared width where there is one,
// and for the ones sharing the remainder whatever content they can fall back
// on — then let the viewport scroll past it.
const minTableWidth = useMemo(() => {
// A column whose cells render bare inputs has no content for the fallback
// to measure, which is exactly the column that collapses; a renamable
// header is an input too, and in a fixed layout the header row is what
// sizes the column.
const inputOnly = (column: (typeof orderedColumns)[number]) =>
Boolean(onColumnRename) || (!column.cell && Boolean(column.editable));
const total = orderedColumns.reduce((sum, column) => {
const resized = widths[column.key];
if (resized != null) return sum + resized;
const declared = resolveColumnWidth(column.width, rootFontSize);
if (declared != null) return sum + declared;
return (
sum +
(inputOnly(column)
? Math.max(minColumnWidth, INPUT_COLUMN_WIDTH)
: minColumnWidth)
);
}, selectable ? CHECKBOX_PX : 0);
return Math.round(total);
}, [
minColumnWidth,
onColumnRename,
orderedColumns,
rootFontSize,
selectable,
widths,
]);
// Infinite scroll: fire onEndReached once per near-bottom dwell, paused while
// loading; the guard resets when the load completes.
const endReachedRef = useRef(false);
useEffect(() => {
if (!loading) endReachedRef.current = false;
}, [loading]);
const handleScroll = useCallback(() => {
const el = scrollRef.current;
if (!el || !onEndReached || loading || endReachedRef.current) return;
if (el.scrollHeight - el.scrollTop - el.clientHeight < rowHeight * 4) {
endReachedRef.current = true;
onEndReached();
}
}, [onEndReached, loading, rowHeight]);
const [activeColumn, setActiveColumn] = useState<string | null>(null);
// Small delay on leave so the pointer can cross the gap from the header cell
// to the portal handle without the column deactivating.
const deactivateTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const activateColumn = useCallback((key: string) => {
if (deactivateTimer.current) clearTimeout(deactivateTimer.current);
deactivateTimer.current = null;
setActiveColumn(key);
}, []);
const deactivateColumn = useCallback(() => {
if (deactivateTimer.current) clearTimeout(deactivateTimer.current);
deactivateTimer.current = setTimeout(() => setActiveColumn(null), 100);
}, []);
const rowRefs = useRef<Record<string, HTMLTableRowElement | null>>({});
const [activeRow, setActiveRow] = useState<{ id: string; index: number } | null>(
null,
);
const rowTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const activateRow = useCallback((id: string, index: number) => {
if (rowTimer.current) clearTimeout(rowTimer.current);
rowTimer.current = null;
setActiveRow({ id, index });
}, []);
const deactivateRow = useCallback(() => {
if (rowTimer.current) clearTimeout(rowTimer.current);
rowTimer.current = setTimeout(() => setActiveRow(null), 100);
}, []);
const activeRowEl = activeRow ? rowRefs.current[activeRow.id] : null;
// Real columns + checkbox; the trailing spacer adds one more in colSpans.
const leadColumns = columns.length + (selectable ? 1 : 0);
return (
<div
className={cn(
"w-full overflow-hidden border border-border bg-background text-sm",
className,
)}
>
<div
ref={scrollRef}
onScroll={handleScroll}
className="overflow-auto"
style={{ height }}
>
<table
className={cn("border-collapse", sized ? "w-max" : undefined)}
style={{
tableLayout: "fixed",
minWidth: `max(100%, ${minTableWidth}px)`,
}}
>
<colgroup>
{selectable ? <col style={{ width: CHECKBOX_WIDTH }} /> : null}
{orderedColumns.map((column) => {
const override = widths[column.key];
const width = override ? `${override}px` : column.width;
return (
<col key={column.key} style={width ? { width } : undefined} />
);
})}
{/* Empty filler owns the leftover space — no gap, content unpinned. */}
<col />
</colgroup>
<TableHeader
columns={orderedColumns}
rowHeight={rowHeight}
reduce={!!reduce}
thRefs={thRefs}
selectable={selectable}
allSelected={allSelected}
someSelected={someSelected}
onToggleAll={toggleAll}
sort={sort}
onToggleSort={toggleSort}
resizable={resizable}
onResizeStart={startResize}
onResizeMove={moveResize}
onResizeEnd={endResize}
reorderable={reorderable}
dragKey={dragKey}
dropIndex={dropIndex}
onReorderStart={startReorder}
onReorderMove={moveReorder}
onReorderEnd={endReorder}
onInsertColumn={onInsertColumn}
onDeleteColumn={onDeleteColumn}
onColumnRename={onColumnRename}
activeColumn={hasColumnMenu ? activeColumn : null}
onColumnActivate={hasColumnMenu ? activateColumn : undefined}
onColumnDeactivate={hasColumnMenu ? deactivateColumn : undefined}
/>
<tbody>
{sortedRows.length === 0 ? (
loading ? (
<SkeletonRows
count={Math.max(1, Math.ceil(height / rowHeight))}
columns={orderedColumns}
selectable={selectable}
rowHeight={rowHeight}
/>
) : (
<tr>
<td
colSpan={leadColumns + 1}
className="p-10 text-center text-muted-foreground"
>
{emptyState}
</td>
</tr>
)
) : (
<>
{paddingTop > 0 ? (
<tr aria-hidden style={{ height: paddingTop }}>
<td colSpan={leadColumns + 1} />
</tr>
) : null}
{virtualItems.map((vItem) => {
const entry = sortedRows[vItem.index];
const isSelected = selected.has(entry.id);
return (
<tr
key={entry.id}
ref={(el) => {
rowRefs.current[entry.id] = el;
}}
data-selected={isSelected}
style={{ height: rowHeight }}
onPointerEnter={
hasRowMenu
? () => activateRow(entry.id, vItem.index)
: undefined
}
onPointerLeave={hasRowMenu ? deactivateRow : undefined}
className={cn(
"border-border/60 border-b transition-colors",
"data-[selected=true]:bg-primary/5",
"hover:bg-muted/50",
)}
>
{selectable ? (
<td className="text-center">
<div className="flex items-center justify-center">
<Checkbox
checked={isSelected}
onCheckedChange={() => toggleRow(entry.id)}
aria-label={`Select row ${vItem.index + 1}`}
/>
</div>
</td>
) : null}
{orderedColumns.map((column) => (
<td
key={column.key}
className={cn(
"truncate px-4 text-foreground",
alignText(column.align),
)}
>
{!column.cell && column.editable ? (
<EditableCell
value={String(readCell(entry.row, column) ?? "")}
label={`${column.key} for row ${vItem.index + 1}`}
onChange={(next) =>
onCellEdit?.(entry.id, column.key, next)
}
/>
) : (
readCell(entry.row, column)
)}
</td>
))}
<td aria-hidden />
</tr>
);
})}
{paddingBottom > 0 ? (
<tr aria-hidden style={{ height: paddingBottom }}>
<td colSpan={leadColumns + 1} />
</tr>
) : null}
{loading ? (
<SkeletonRows
count={skeletonRows}
columns={orderedColumns}
selectable={selectable}
rowHeight={rowHeight}
/>
) : null}
</>
)}
</tbody>
</table>
</div>
{hasRowMenu && activeRow ? (
<RowHandle
rowEl={activeRowEl}
id={activeRow.id}
index={activeRow.index}
onInsertRow={onInsertRow}
onDeleteRow={onDeleteRow}
onEnter={() => activateRow(activeRow.id, activeRow.index)}
onLeave={deactivateRow}
/>
) : null}
</div>
);
}
Install
shadcn init? You are set. Theme setupInstall dependencies
npm i @tanstack/react-virtual clsx lucide-react motion tailwind-mergeAdd util files
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
// Shared touch primitives. iOS and iPadOS run their own gestures on top of the
// page — the long-press selection callout and the selection it drags in with
// it — and they win: once the platform claims a touch it cancels ours
// mid-gesture, so a press-and-hold or a drag simply dies. Surfaces that own
// their gesture have to opt out.
//
// What the two classes below cover, precisely:
// - `-webkit-touch-callout: none` stops iOS's long-press callout. WebKit-only:
// it is not a property other engines have, so it is inert everywhere else.
// - `user-select: none` stops the long-press selection on every engine,
// Android included, and stops a drag from painting a selection under the
// cursor. It is inherited, so it reaches every descendant — which is why the
// two classes differ only in whether they apply it unconditionally.
// What neither covers:
// - Chrome for Android's long-press menu on a link or an image. No CSS
// suppresses it; a gesture surface that wraps one needs its own
// `onContextMenu` with `preventDefault()`.
// - The native drag of an `<img>` or `<a>` descendant. `-webkit-user-drag` is
// not inherited and plain divs and buttons are not drag sources, so setting
// it on the surface does nothing — the child itself needs `draggable={false}`.
/**
* Classes for a surface that *is* the control: a thumb, a drum, a stage, a
* handle, a hold button. Selection is suppressed on every input, because a
* drag that highlights the control's own label is wrong on a mouse too.
* Compose with `touch-none` when the surface also owns the scroll axis — leave
* it off when the page must still scroll from there.
*/
export const TOUCH_GESTURE_CLASS = "select-none [-webkit-touch-callout:none]";
/**
* The same opt-out for a gesture surface that wraps content the consumer owns:
* a scroller, a context-menu trigger, a sheet header, a list row. Selection is
* suppressed only where the platform runs its own press gestures — a coarse
* pointer — so a mouse user can still select and copy that content. If the
* gesture itself would paint a selection under the cursor, add `select-none`
* for the duration of the gesture rather than reaching for
* `TOUCH_GESTURE_CLASS`.
*
* `pointer: coarse` describes the *primary* pointer and nothing else, so a
* hybrid machine reads it wrong in both directions: a tablet with a mouse
* plugged in keeps touch as primary and loses mouse selection, and a laptop
* with a touchscreen keeps the mouse as primary and leaves selection live
* under a finger. No media query can answer per interaction — the query is
* about the device, and the question is about the gesture in progress. The
* default stays here because it is right on the machines that are one thing or
* the other, and losing a selection is a nuisance; where the miss costs a
* *gesture* instead, the surface pairs it with `holdSelection` on the press.
*/
export const TOUCH_GESTURE_CONTENT_CLASS =
"[-webkit-touch-callout:none] pointer-coarse:select-none";
/**
* Suppress selection on `element` for as long as a gesture is running on it,
* whatever the primary pointer of the machine happens to be. Returns the
* release. Inline, so it wins over the class above and is gone again the
* moment the gesture ends.
*
* For the press gestures a native selection would otherwise steal — a
* long-press that opens a menu. Elsewhere prefer the classes: a surface that
* takes selection away for the whole session is a surface whose text nobody
* can copy.
*/
export function holdSelection(element: HTMLElement) {
element.style.setProperty("user-select", "none");
element.style.setProperty("-webkit-user-select", "none");
return () => {
element.style.removeProperty("user-select");
element.style.removeProperty("-webkit-user-select");
};
}
/**
* Pointer capture, best effort. WebKit throws `NotFoundError` when the pointer
* is already gone by the time the handler runs — routine on iOS, where the
* system can claim the touch first — and an uncaught throw takes the rest of
* the handler, the gesture included, down with it. Touch pointers carry
* implicit capture anyway, so losing it is never fatal.
*/
export function capturePointer(element: Element, pointerId: number) {
try {
element.setPointerCapture(pointerId);
} catch {
// Pointer is no longer active — implicit capture still applies on touch.
}
}
/** Release a capture taken with `capturePointer`, ignoring a stale pointer. */
export function releasePointer(element: Element, pointerId: number) {
try {
if (element.hasPointerCapture(pointerId)) {
element.releasePointerCapture(pointerId);
}
} catch {
// Capture was already dropped by the browser.
}
}
/**
* Whether this event came from a pointer that is *hovering*: not a touch, and
* not currently pressed. Which input the user is holding right now is not
* something a device capability can answer — a touchscreen laptop hovers and
* taps, and iPadOS reports a fine hovering pointer for a finger — so both
* paths stay live and each handler branches on the event it was given.
*
* A pen resting on the glass is making contact, not hovering: `buttons` is the
* tell, and it sends a pen tap down the same route a finger takes.
*
* This answers what an *enter* asks. A leave is the other half of a pair and
* has to be read against the enter that started it — `useHoverGesture` in
* `lib/hooks/use-hover-gesture` does that, and hover surfaces should use it
* rather than asking this question twice.
*/
export const isHoveringPointer = (event: {
pointerType: string;
buttons: number;
}) => event.pointerType !== "touch" && event.buttons === 0;
Copy the source code
"use client";
// beui.dev/components/motion/table
import { useVirtualizer } from "@tanstack/react-virtual";
import { useReducedMotion } from "motion/react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Checkbox } from "@/components/motion/checkbox";
import { cn } from "@/lib/utils";
import { EditableCell } from "./editable-cell";
import { RowHandle } from "./row-handle";
import { SkeletonRows } from "./skeleton-rows";
import { TableHeader } from "./table-header";
import type { HeaderCellRefs, TableProps } from "./types";
import { useColumnReorder } from "./use-column-reorder";
import { useColumnResize } from "./use-column-resize";
import { useColumnSort } from "./use-column-sort";
import { useRowSelection } from "./use-row-selection";
import { alignText, CHECKBOX_PX, CHECKBOX_WIDTH, readCell } from "./utils";
export type {
SortDirection,
SortState,
TableColumn,
TableProps,
} from "./types";
/**
* Narrowest a column of bare inputs may be floored to and still show a value:
* the cell's own `px-4` eats 32 of it.
*/
const INPUT_COLUMN_WIDTH = 120;
/** The root font size Tailwind's rem scale assumes, and the pre-measure guess. */
const DEFAULT_ROOT_FONT_SIZE = 16;
/**
* What one `rem` is worth here, in px. The default until the first client
* layout, so the server and the hydrating client emit the same floor; measured
* once after that, because a document that sets its own `html { font-size }`
* lays a rem column out against that size and a floor computed from 16 would
* fall short by the same factor.
*/
function useRootFontSize() {
const [size, setSize] = useState(DEFAULT_ROOT_FONT_SIZE);
useEffect(() => {
const measured = Number.parseFloat(
getComputedStyle(document.documentElement).fontSize,
);
if (measured > 0) setSize(measured);
}, []);
return size;
}
/**
* The absolute width a column declared, in px, or null when it declared a share
* of the remainder instead (`fr`, `%`, `auto`, `calc()`, nothing at all) — those
* are worth whatever is left over, which is not a width this can add up.
*/
function resolveColumnWidth(
width: string | undefined,
rootFontSize: number,
): number | null {
if (!width) return null;
const value = Number.parseFloat(width);
if (!Number.isFinite(value)) return null;
if (width.endsWith("px")) return value;
// rem is the other absolute length the repo writes.
if (width.endsWith("rem")) return value * rootFontSize;
return null;
}
export function Table<T>({
data,
columns,
getRowId,
selectable = false,
selectedRowIds,
defaultSelectedRowIds,
onSelectionChange,
sort: sortProp,
defaultSort = null,
onSortChange,
resizable = false,
minColumnWidth = 64,
onColumnResize,
reorderable = false,
onColumnOrderChange,
onCellEdit,
onColumnRename,
onInsertRow,
onDeleteRow,
onInsertColumn,
onDeleteColumn,
rowHeight = 48,
height = 440,
overscan = 10,
onEndReached,
loading = false,
skeletonRows = 3,
emptyState = "No data",
className,
}: TableProps<T>) {
const reduce = useReducedMotion();
const scrollRef = useRef<HTMLDivElement>(null);
const thRefs: HeaderCellRefs = useRef<
Record<string, HTMLTableCellElement | null>
>({});
const rows = useMemo(
() =>
data.map((row, index) => ({
row,
id: getRowId ? getRowId(row, index) : String(index),
})),
[data, getRowId],
);
const {
orderedColumns,
dragKey,
dropIndex,
startReorder,
moveReorder,
endReorder,
} = useColumnReorder({ columns, thRefs, onColumnOrderChange });
const { sort, sortedRows, toggleSort } = useColumnSort({
rows,
columns,
sort: sortProp,
defaultSort,
onSortChange,
});
const { widths, startResize, moveResize, endResize } = useColumnResize({
orderedColumns,
thRefs,
minColumnWidth,
onColumnResize,
});
const { selected, allSelected, someSelected, toggleAll, toggleRow } =
useRowSelection({
sortedRows,
selectedRowIds,
defaultSelectedRowIds,
onSelectionChange,
});
const virtualizer = useVirtualizer({
count: sortedRows.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => rowHeight,
overscan,
});
const virtualItems = virtualizer.getVirtualItems();
const totalSize = virtualizer.getTotalSize();
const paddingTop = virtualItems.length > 0 ? virtualItems[0].start : 0;
const paddingBottom =
virtualItems.length > 0
? totalSize - virtualItems[virtualItems.length - 1].end
: 0;
const hasRowMenu = !!(onInsertRow || onDeleteRow);
const hasColumnMenu = !!(onInsertColumn || onDeleteColumn);
// Only shrink-wrap (w-max) once every column has an explicit resized width;
// otherwise stay fill-width so a flexible column can't size to cell content.
const sized =
orderedColumns.length > 0 &&
orderedColumns.every((c) => widths[c.key] != null);
const rootFontSize = useRootFontSize();
// In a container narrower than the columns, `table-layout: fixed` shrinks
// every column toward zero instead of scrolling. Floor the table at what the
// columns actually asked for — an absolute declared width where there is one,
// and for the ones sharing the remainder whatever content they can fall back
// on — then let the viewport scroll past it.
const minTableWidth = useMemo(() => {
// A column whose cells render bare inputs has no content for the fallback
// to measure, which is exactly the column that collapses; a renamable
// header is an input too, and in a fixed layout the header row is what
// sizes the column.
const inputOnly = (column: (typeof orderedColumns)[number]) =>
Boolean(onColumnRename) || (!column.cell && Boolean(column.editable));
const total = orderedColumns.reduce((sum, column) => {
const resized = widths[column.key];
if (resized != null) return sum + resized;
const declared = resolveColumnWidth(column.width, rootFontSize);
if (declared != null) return sum + declared;
return (
sum +
(inputOnly(column)
? Math.max(minColumnWidth, INPUT_COLUMN_WIDTH)
: minColumnWidth)
);
}, selectable ? CHECKBOX_PX : 0);
return Math.round(total);
}, [
minColumnWidth,
onColumnRename,
orderedColumns,
rootFontSize,
selectable,
widths,
]);
// Infinite scroll: fire onEndReached once per near-bottom dwell, paused while
// loading; the guard resets when the load completes.
const endReachedRef = useRef(false);
useEffect(() => {
if (!loading) endReachedRef.current = false;
}, [loading]);
const handleScroll = useCallback(() => {
const el = scrollRef.current;
if (!el || !onEndReached || loading || endReachedRef.current) return;
if (el.scrollHeight - el.scrollTop - el.clientHeight < rowHeight * 4) {
endReachedRef.current = true;
onEndReached();
}
}, [onEndReached, loading, rowHeight]);
const [activeColumn, setActiveColumn] = useState<string | null>(null);
// Small delay on leave so the pointer can cross the gap from the header cell
// to the portal handle without the column deactivating.
const deactivateTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const activateColumn = useCallback((key: string) => {
if (deactivateTimer.current) clearTimeout(deactivateTimer.current);
deactivateTimer.current = null;
setActiveColumn(key);
}, []);
const deactivateColumn = useCallback(() => {
if (deactivateTimer.current) clearTimeout(deactivateTimer.current);
deactivateTimer.current = setTimeout(() => setActiveColumn(null), 100);
}, []);
const rowRefs = useRef<Record<string, HTMLTableRowElement | null>>({});
const [activeRow, setActiveRow] = useState<{ id: string; index: number } | null>(
null,
);
const rowTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const activateRow = useCallback((id: string, index: number) => {
if (rowTimer.current) clearTimeout(rowTimer.current);
rowTimer.current = null;
setActiveRow({ id, index });
}, []);
const deactivateRow = useCallback(() => {
if (rowTimer.current) clearTimeout(rowTimer.current);
rowTimer.current = setTimeout(() => setActiveRow(null), 100);
}, []);
const activeRowEl = activeRow ? rowRefs.current[activeRow.id] : null;
// Real columns + checkbox; the trailing spacer adds one more in colSpans.
const leadColumns = columns.length + (selectable ? 1 : 0);
return (
<div
className={cn(
"w-full overflow-hidden border border-border bg-background text-sm",
className,
)}
>
<div
ref={scrollRef}
onScroll={handleScroll}
className="overflow-auto"
style={{ height }}
>
<table
className={cn("border-collapse", sized ? "w-max" : undefined)}
style={{
tableLayout: "fixed",
minWidth: `max(100%, ${minTableWidth}px)`,
}}
>
<colgroup>
{selectable ? <col style={{ width: CHECKBOX_WIDTH }} /> : null}
{orderedColumns.map((column) => {
const override = widths[column.key];
const width = override ? `${override}px` : column.width;
return (
<col key={column.key} style={width ? { width } : undefined} />
);
})}
{/* Empty filler owns the leftover space — no gap, content unpinned. */}
<col />
</colgroup>
<TableHeader
columns={orderedColumns}
rowHeight={rowHeight}
reduce={!!reduce}
thRefs={thRefs}
selectable={selectable}
allSelected={allSelected}
someSelected={someSelected}
onToggleAll={toggleAll}
sort={sort}
onToggleSort={toggleSort}
resizable={resizable}
onResizeStart={startResize}
onResizeMove={moveResize}
onResizeEnd={endResize}
reorderable={reorderable}
dragKey={dragKey}
dropIndex={dropIndex}
onReorderStart={startReorder}
onReorderMove={moveReorder}
onReorderEnd={endReorder}
onInsertColumn={onInsertColumn}
onDeleteColumn={onDeleteColumn}
onColumnRename={onColumnRename}
activeColumn={hasColumnMenu ? activeColumn : null}
onColumnActivate={hasColumnMenu ? activateColumn : undefined}
onColumnDeactivate={hasColumnMenu ? deactivateColumn : undefined}
/>
<tbody>
{sortedRows.length === 0 ? (
loading ? (
<SkeletonRows
count={Math.max(1, Math.ceil(height / rowHeight))}
columns={orderedColumns}
selectable={selectable}
rowHeight={rowHeight}
/>
) : (
<tr>
<td
colSpan={leadColumns + 1}
className="p-10 text-center text-muted-foreground"
>
{emptyState}
</td>
</tr>
)
) : (
<>
{paddingTop > 0 ? (
<tr aria-hidden style={{ height: paddingTop }}>
<td colSpan={leadColumns + 1} />
</tr>
) : null}
{virtualItems.map((vItem) => {
const entry = sortedRows[vItem.index];
const isSelected = selected.has(entry.id);
return (
<tr
key={entry.id}
ref={(el) => {
rowRefs.current[entry.id] = el;
}}
data-selected={isSelected}
style={{ height: rowHeight }}
onPointerEnter={
hasRowMenu
? () => activateRow(entry.id, vItem.index)
: undefined
}
onPointerLeave={hasRowMenu ? deactivateRow : undefined}
className={cn(
"border-border/60 border-b transition-colors",
"data-[selected=true]:bg-primary/5",
"hover:bg-muted/50",
)}
>
{selectable ? (
<td className="text-center">
<div className="flex items-center justify-center">
<Checkbox
checked={isSelected}
onCheckedChange={() => toggleRow(entry.id)}
aria-label={`Select row ${vItem.index + 1}`}
/>
</div>
</td>
) : null}
{orderedColumns.map((column) => (
<td
key={column.key}
className={cn(
"truncate px-4 text-foreground",
alignText(column.align),
)}
>
{!column.cell && column.editable ? (
<EditableCell
value={String(readCell(entry.row, column) ?? "")}
label={`${column.key} for row ${vItem.index + 1}`}
onChange={(next) =>
onCellEdit?.(entry.id, column.key, next)
}
/>
) : (
readCell(entry.row, column)
)}
</td>
))}
<td aria-hidden />
</tr>
);
})}
{paddingBottom > 0 ? (
<tr aria-hidden style={{ height: paddingBottom }}>
<td colSpan={leadColumns + 1} />
</tr>
) : null}
{loading ? (
<SkeletonRows
count={skeletonRows}
columns={orderedColumns}
selectable={selectable}
rowHeight={rowHeight}
/>
) : null}
</>
)}
</tbody>
</table>
</div>
{hasRowMenu && activeRow ? (
<RowHandle
rowEl={activeRowEl}
id={activeRow.id}
index={activeRow.index}
onInsertRow={onInsertRow}
onDeleteRow={onDeleteRow}
onEnter={() => activateRow(activeRow.id, activeRow.index)}
onLeave={deactivateRow}
/>
) : null}
</div>
);
}
"use client";
export function EditableCell({
value,
label,
onChange,
}: {
value: string;
label: string;
onChange: (next: string) => void;
}) {
return (
<input
value={value}
aria-label={label}
size={1}
onChange={(e) => onChange(e.target.value)}
placeholder="Empty"
className="-mx-2 w-full min-w-0 appearance-none rounded-md border-0 bg-transparent px-2 py-1 text-foreground outline-none transition-colors placeholder:text-muted-foreground/40 focus:bg-muted focus:ring-1 focus:ring-ring"
/>
);
}
"use client";
import { ArrowDownToLine, ArrowUpToLine, MoreVertical, Trash2 } from "lucide-react";
import { useEffect } from "react";
import { createPortal } from "react-dom";
import { TableMenu } from "./table-menu";
/** The row handle, portaled so it can sit on the row's left border without the
* scroll container clipping it. Straddles the border to bridge hover. */
export function RowHandle({
rowEl,
id,
index,
onInsertRow,
onDeleteRow,
onEnter,
onLeave,
}: {
rowEl: HTMLTableRowElement | null;
id: string;
index: number;
onInsertRow?: (index: number, position: "before" | "after") => void;
onDeleteRow?: (rowId: string, index: number) => void;
onEnter: () => void;
onLeave: () => void;
}) {
useEffect(() => {
window.addEventListener("scroll", onLeave, true);
return () => window.removeEventListener("scroll", onLeave, true);
}, [onLeave]);
if (!rowEl || typeof document === "undefined") return null;
const rect = rowEl.getBoundingClientRect();
return createPortal(
<div
style={{
position: "fixed",
top: rect.top + rect.height / 2,
left: rect.left,
transform: "translate(-50%, -50%)",
zIndex: 40,
}}
onPointerEnter={onEnter}
onPointerLeave={onLeave}
>
<TableMenu
ariaLabel={`Row ${index + 1} options`}
triggerClassName="flex h-6 w-2 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-sm transition-colors hover:bg-primary/90"
trigger={<MoreVertical className="h-3 w-3" />}
items={[
...(onInsertRow
? [
{
label: "Insert before",
icon: <ArrowUpToLine />,
onSelect: () => onInsertRow(index, "before"),
},
{
label: "Insert after",
icon: <ArrowDownToLine />,
onSelect: () => onInsertRow(index, "after"),
},
]
: []),
...(onDeleteRow
? [
{
label: "Delete row",
icon: <Trash2 />,
destructive: true,
onSelect: () => onDeleteRow(id, index),
},
]
: []),
]}
/>
</div>,
document.body,
);
}
"use client";
import { cn } from "@/lib/utils";
import type { TableColumn } from "./types";
import { alignText } from "./utils";
export function SkeletonRows<T>({
count,
columns,
selectable,
rowHeight,
}: {
count: number;
columns: TableColumn<T>[];
selectable: boolean;
rowHeight: number;
}) {
return (
<>
{Array.from({ length: count }, (_, r) => (
// biome-ignore lint/suspicious/noArrayIndexKey: static placeholder rows
<tr key={r} style={{ height: rowHeight }} className="border-border/60 border-b">
{selectable ? <td /> : null}
{columns.map((column) => (
<td key={column.key} className={cn("px-4", alignText(column.align))}>
<div
className={cn(
"h-3 animate-pulse rounded-full bg-muted",
column.align === "right" ? "ml-auto w-10" : "w-2/3",
)}
/>
</td>
))}
<td aria-hidden />
</tr>
))}
</>
);
}
"use client";
import {
ArrowLeftToLine,
ArrowRightToLine,
ChevronUp,
GripVertical,
MoreHorizontal,
Trash2,
} from "lucide-react";
import { motion } from "motion/react";
import { type PointerEvent as ReactPointerEvent, useEffect } from "react";
import { createPortal } from "react-dom";
import { Checkbox } from "@/components/motion/checkbox";
import { EASE_OUT, SPRING_PRESS } from "@/lib/ease";
import { TOUCH_GESTURE_CLASS } from "@/lib/touch";
import { cn } from "@/lib/utils";
import { TableMenu } from "./table-menu";
import type {
HeaderCellRefs,
InsertPosition,
SortState,
TableColumn,
} from "./types";
import { alignFlex, alignText, COLUMN_ACTIVE_SHADOW } from "./utils";
export interface TableHeaderProps<T> {
columns: TableColumn<T>[];
rowHeight: number;
reduce: boolean;
thRefs: HeaderCellRefs;
selectable: boolean;
allSelected: boolean;
someSelected: boolean;
onToggleAll: () => void;
sort: SortState | null;
onToggleSort: (key: string) => void;
resizable: boolean;
onResizeStart: (key: string, e: ReactPointerEvent) => void;
onResizeMove: (e: ReactPointerEvent) => void;
onResizeEnd: (e: ReactPointerEvent) => void;
reorderable: boolean;
dragKey: string | null;
dropIndex: number | null;
onReorderStart: (key: string, e: ReactPointerEvent) => void;
onReorderMove: (e: ReactPointerEvent) => void;
onReorderEnd: (e: ReactPointerEvent) => void;
onInsertColumn?: (index: number, position: InsertPosition) => void;
onDeleteColumn?: (columnKey: string, index: number) => void;
onColumnRename?: (columnKey: string, value: string) => void;
activeColumn: string | null;
onColumnActivate?: (key: string) => void;
onColumnDeactivate?: () => void;
}
/** Column insert / delete menu items shared by the header cell and the portal handle. */
function columnMenuItems<T>(
column: TableColumn<T>,
index: number,
onInsertColumn?: (index: number, position: InsertPosition) => void,
onDeleteColumn?: (columnKey: string, index: number) => void,
) {
return [
...(onInsertColumn
? [
{
label: "Insert before",
icon: <ArrowLeftToLine />,
onSelect: () => onInsertColumn(index, "before"),
},
{
label: "Insert after",
icon: <ArrowRightToLine />,
onSelect: () => onInsertColumn(index, "after"),
},
]
: []),
...(onDeleteColumn
? [
{
label: "Delete column",
icon: <Trash2 />,
destructive: true,
onSelect: () => onDeleteColumn(column.key, index),
},
]
: []),
];
}
/** The ellipse handle, portaled so it can sit on the column's top border without
* the scroll container clipping it. Straddles the border to bridge hover. */
function ColumnHandle<T>({
column,
index,
thRefs,
onInsertColumn,
onDeleteColumn,
onEnter,
onLeave,
}: {
column: TableColumn<T>;
index: number;
thRefs: HeaderCellRefs;
onInsertColumn?: (index: number, position: InsertPosition) => void;
onDeleteColumn?: (columnKey: string, index: number) => void;
onEnter: () => void;
onLeave: () => void;
}) {
useEffect(() => {
window.addEventListener("scroll", onLeave, true);
return () => window.removeEventListener("scroll", onLeave, true);
}, [onLeave]);
const el = thRefs.current[column.key];
if (!el || typeof document === "undefined") return null;
const rect = el.getBoundingClientRect();
return createPortal(
<div
style={{
position: "fixed",
top: rect.top,
left: rect.left + rect.width / 2,
transform: "translate(-50%, -50%)",
zIndex: 40,
}}
onPointerEnter={onEnter}
onPointerLeave={onLeave}
>
<TableMenu
ariaLabel={`${column.key} column options`}
triggerClassName="flex h-2 w-6 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-sm transition-colors hover:bg-primary/90"
trigger={<MoreHorizontal className="h-3 w-3" />}
items={columnMenuItems(column, index, onInsertColumn, onDeleteColumn)}
/>
</div>,
document.body,
);
}
export function TableHeader<T>({
columns,
rowHeight,
reduce,
thRefs,
selectable,
allSelected,
someSelected,
onToggleAll,
sort,
onToggleSort,
resizable,
onResizeStart,
onResizeMove,
onResizeEnd,
reorderable,
dragKey,
dropIndex,
onReorderStart,
onReorderMove,
onReorderEnd,
onInsertColumn,
onDeleteColumn,
onColumnRename,
activeColumn,
onColumnActivate,
onColumnDeactivate,
}: TableHeaderProps<T>) {
const hasColumnMenu = !!(onInsertColumn || onDeleteColumn);
const activeIndex = columns.findIndex((c) => c.key === activeColumn);
return (
<>
{hasColumnMenu && activeColumn && activeIndex >= 0 ? (
<ColumnHandle
column={columns[activeIndex]}
index={activeIndex}
thRefs={thRefs}
onInsertColumn={onInsertColumn}
onDeleteColumn={onDeleteColumn}
onEnter={() => onColumnActivate?.(activeColumn)}
onLeave={() => onColumnDeactivate?.()}
/>
) : null}
<thead>
<tr style={{ height: rowHeight }}>
{selectable ? (
<th className="sticky top-0 z-10 border-border border-b bg-muted">
<div className="flex items-center justify-center">
<Checkbox
checked={allSelected}
indeterminate={!allSelected && someSelected}
onCheckedChange={onToggleAll}
aria-label="Select all rows"
className="size-6"
/>
</div>
</th>
) : null}
{columns.map((column, index) => {
const active = sort?.key === column.key;
const isDragging = dragKey === column.key;
const isActive = activeColumn === column.key;
return (
<th
key={column.key}
ref={(el) => {
thRefs.current[column.key] = el;
}}
onPointerEnter={() => onColumnActivate?.(column.key)}
onPointerLeave={() => onColumnDeactivate?.()}
style={isActive ? { boxShadow: COLUMN_ACTIVE_SHADOW } : undefined}
aria-sort={
active
? sort?.direction === "asc"
? "ascending"
: "descending"
: undefined
}
data-drop={dragKey ? dropIndex === index : undefined}
data-dropend={
dragKey
? dropIndex === columns.length && index === columns.length - 1
: undefined
}
className={cn(
"group sticky top-0 z-10 border-border border-b bg-muted p-0 font-medium text-muted-foreground",
"data-[drop=true]:before:absolute data-[drop=true]:before:inset-y-0 data-[drop=true]:before:left-0 data-[drop=true]:before:w-0.5 data-[drop=true]:before:bg-primary",
"data-[dropend=true]:after:absolute data-[dropend=true]:after:inset-y-0 data-[dropend=true]:after:right-0 data-[dropend=true]:after:w-0.5 data-[dropend=true]:after:bg-primary",
)}
>
<motion.div
className={cn(
"flex h-full items-center",
alignFlex(column.align),
)}
style={{ height: rowHeight }}
animate={
reduce
? { opacity: isDragging ? 0.5 : 1 }
: {
scale: isDragging ? 1.04 : 1,
opacity: isDragging ? 0.5 : 1,
}
}
transition={SPRING_PRESS}
>
{reorderable ? (
<button
type="button"
aria-label={`Reorder ${column.key} column`}
onPointerDown={(e) => onReorderStart(column.key, e)}
onPointerMove={onReorderMove}
onPointerUp={onReorderEnd}
className={cn(
"flex h-full w-6 cursor-grab touch-none items-center justify-center text-muted-foreground/60 transition-colors hover:text-foreground active:cursor-grabbing",
// The grip owns the whole press, so iOS must not open its
// callout out of the same one and cancel the drag.
TOUCH_GESTURE_CLASS,
)}
>
<GripVertical className="h-3.5 w-3.5" />
</button>
) : null}
{column.sortable ? (
<button
type="button"
onClick={() => onToggleSort(column.key)}
className={cn(
"flex h-full min-w-0 flex-1 select-none items-center gap-1 px-4 transition-colors hover:text-foreground",
alignFlex(column.align),
active && "text-foreground",
)}
>
<span className="truncate">{column.header}</span>
<motion.span
aria-hidden
className="inline-flex shrink-0"
animate={{
rotate: active && sort?.direction === "desc" ? 180 : 0,
opacity: active ? 1 : 0.35,
}}
transition={
reduce
? { duration: 0 }
: { duration: 0.18, ease: EASE_OUT }
}
>
<ChevronUp className="h-3.5 w-3.5" />
</motion.span>
</button>
) : onColumnRename ? (
<input
value={
typeof column.header === "string" ? column.header : ""
}
aria-label={`Rename ${column.key} column`}
size={1}
onChange={(e) =>
onColumnRename(column.key, e.target.value)
}
className={cn(
"min-w-0 flex-1 truncate appearance-none rounded-md border-0 bg-transparent px-4 font-medium text-muted-foreground outline-none transition-colors focus:bg-muted focus:text-foreground",
alignText(column.align),
)}
/>
) : (
<span
className={cn(
"min-w-0 flex-1 truncate px-4",
alignText(column.align),
)}
>
{column.header}
</span>
)}
</motion.div>
{resizable ? (
<button
type="button"
aria-label={`Resize ${column.key} column`}
tabIndex={-1}
onPointerDown={(e) => onResizeStart(column.key, e)}
onPointerMove={onResizeMove}
onPointerUp={onResizeEnd}
className={cn(
"absolute top-0 right-0 h-full w-1.5 cursor-col-resize touch-none bg-transparent transition-colors hover:bg-primary/40",
// Same for the resize drag: the handle drives it end to end.
TOUCH_GESTURE_CLASS,
)}
/>
) : null}
</th>
);
})}
<th
aria-hidden
className="sticky top-0 z-10 border-border border-b bg-muted"
/>
</tr>
</thead>
</>
);
}
import type { ReactNode } from "react";
export type SortDirection = "asc" | "desc";
export type SortState = {
key: string;
direction: SortDirection;
};
export type TableColumn<T> = {
/** Stable key; also the default object property read for the cell + sort value. */
key: string;
/** Header content. */
header: ReactNode;
/** Allow clicking the header to sort by this column. */
sortable?: boolean;
/** Cell text alignment. */
align?: "left" | "center" | "right";
/** Column width as a CSS length, e.g. "160px" or "20%". Omit to share remaining space equally. */
width?: string;
/** Custom cell renderer. Falls back to `row[key]`. */
cell?: (row: T) => ReactNode;
/** Render an inline text input for this column's cells (ignored when `cell` is set). */
editable?: boolean;
/** Value used for sorting. Falls back to `row[key]`. */
sortValue?: (row: T) => string | number;
};
export type InsertPosition = "before" | "after";
export interface TableProps<T> {
data: T[];
columns: TableColumn<T>[];
/** Stable id per row, required for correct selection across sorts. Defaults to row index. */
getRowId?: (row: T, index: number) => string;
/** Render a leading checkbox column with select-all in the header. */
selectable?: boolean;
selectedRowIds?: string[];
defaultSelectedRowIds?: string[];
onSelectionChange?: (ids: string[]) => void;
sort?: SortState | null;
defaultSort?: SortState | null;
onSortChange?: (sort: SortState | null) => void;
/** Allow dragging the right edge of a header to resize that column. */
resizable?: boolean;
/** Minimum column width in px when resizing. */
minColumnWidth?: number;
onColumnResize?: (key: string, width: number) => void;
/** Allow dragging a header grip to reorder columns. */
reorderable?: boolean;
onColumnOrderChange?: (keys: string[]) => void;
/** Called when an `editable` cell changes. */
onCellEdit?: (rowId: string, columnKey: string, value: string) => void;
/** When set, non-sortable headers become editable inputs for the column name. */
onColumnRename?: (columnKey: string, value: string) => void;
/** Enables the row menu (Insert before / after). Receives the target index. */
onInsertRow?: (index: number, position: InsertPosition) => void;
/** Enables Delete in the row menu. */
onDeleteRow?: (rowId: string, index: number) => void;
/** Enables the column menu (Insert before / after). Receives the target column index. */
onInsertColumn?: (index: number, position: InsertPosition) => void;
/** Enables Delete in the column menu. */
onDeleteColumn?: (columnKey: string, index: number) => void;
/** Fixed row height in px — required for virtualization. */
rowHeight?: number;
/** Scroll viewport height in px. */
height?: number;
/** Rows rendered above/below the viewport. */
overscan?: number;
/** Fires when the viewport scrolls near the bottom — load the next page. */
onEndReached?: () => void;
/** Currently fetching — shows skeleton rows and pauses `onEndReached`. */
loading?: boolean;
/** How many skeleton rows to show while loading more (default 3). */
skeletonRows?: number;
emptyState?: ReactNode;
className?: string;
}
/** A data row paired with its stable id. */
export type TableRow<T> = { row: T; id: string };
/** Ref map from column key to its header cell, shared across the resize/reorder hooks. */
export type HeaderCellRefs = {
current: Record<string, HTMLTableCellElement | null>;
};
import {
type PointerEvent as ReactPointerEvent,
useCallback,
useMemo,
useState,
} from "react";
import { capturePointer, releasePointer } from "@/lib/touch";
import type { HeaderCellRefs, TableColumn } from "./types";
export function useColumnReorder<T>({
columns,
thRefs,
onColumnOrderChange,
}: {
columns: TableColumn<T>[];
thRefs: HeaderCellRefs;
onColumnOrderChange?: (keys: string[]) => void;
}) {
const [order, setOrder] = useState<string[]>(() =>
columns.map((c) => c.key),
);
const [dragKey, setDragKey] = useState<string | null>(null);
const [dropIndex, setDropIndex] = useState<number | null>(null);
// Apply the current order, tolerating columns added/removed at runtime. New
// columns are placed at their position in `columns` (after their left
// neighbor), not appended — so an inserted column lands where it was added.
const orderedColumns = useMemo(() => {
const byKey = new Map(columns.map((c) => [c.key, c]));
const resultKeys = order.filter((k) => byKey.has(k));
const present = new Set(resultKeys);
columns.forEach((column, i) => {
if (present.has(column.key)) return;
let at = resultKeys.length;
if (i === 0) {
at = 0;
} else {
const idx = resultKeys.indexOf(columns[i - 1].key);
at = idx === -1 ? i : idx + 1;
}
resultKeys.splice(at, 0, column.key);
present.add(column.key);
});
return resultKeys
.map((k) => byKey.get(k))
.filter((c): c is TableColumn<T> => c !== undefined);
}, [order, columns]);
const dropIndexFor = useCallback(
(clientX: number) => {
for (let i = 0; i < orderedColumns.length; i++) {
const rect =
thRefs.current[orderedColumns[i].key]?.getBoundingClientRect();
if (rect && clientX < rect.left + rect.width / 2) return i;
}
return orderedColumns.length;
},
[orderedColumns, thRefs],
);
const startReorder = useCallback((key: string, e: ReactPointerEvent) => {
e.preventDefault();
e.stopPropagation();
setDragKey(key);
capturePointer(e.currentTarget, e.pointerId);
}, []);
const moveReorder = useCallback(
(e: ReactPointerEvent) => {
if (!dragKey) return;
setDropIndex(dropIndexFor(e.clientX));
},
[dragKey, dropIndexFor],
);
const endReorder = useCallback(
(e: ReactPointerEvent) => {
releasePointer(e.currentTarget, e.pointerId);
if (dragKey && dropIndex !== null) {
const keys = orderedColumns.map((c) => c.key);
const from = keys.indexOf(dragKey);
if (from !== -1) {
const without = keys.filter((_, i) => i !== from);
let to = dropIndex;
if (from < to) to--;
without.splice(to, 0, dragKey);
setOrder(without);
onColumnOrderChange?.(without);
}
}
setDragKey(null);
setDropIndex(null);
},
[dragKey, dropIndex, orderedColumns, onColumnOrderChange],
);
return {
orderedColumns,
dragKey,
dropIndex,
startReorder,
moveReorder,
endReorder,
};
}
import {
type PointerEvent as ReactPointerEvent,
useCallback,
useRef,
useState,
} from "react";
import { capturePointer, releasePointer } from "@/lib/touch";
import type { HeaderCellRefs, TableColumn } from "./types";
export function useColumnResize<T>({
orderedColumns,
thRefs,
minColumnWidth,
onColumnResize,
}: {
orderedColumns: TableColumn<T>[];
thRefs: HeaderCellRefs;
minColumnWidth: number;
onColumnResize?: (key: string, width: number) => void;
}) {
const resizeRef = useRef<{
key: string;
startX: number;
startWidth: number;
} | null>(null);
const [widths, setWidths] = useState<Record<string, number>>({});
const startResize = useCallback(
(key: string, e: ReactPointerEvent) => {
e.preventDefault();
e.stopPropagation();
// Freeze every column to its current pixel width so resizing one only
// moves the trailing spacer, never the other columns.
const snapshot = { ...widths };
for (const column of orderedColumns) {
if (snapshot[column.key] == null) {
const measured = thRefs.current[column.key]?.getBoundingClientRect()
.width;
snapshot[column.key] = measured
? Math.round(measured)
: minColumnWidth;
}
}
resizeRef.current = {
key,
startX: e.clientX,
startWidth: snapshot[key],
};
setWidths(snapshot);
capturePointer(e.currentTarget, e.pointerId);
},
[minColumnWidth, orderedColumns, thRefs, widths],
);
const moveResize = useCallback(
(e: ReactPointerEvent) => {
const state = resizeRef.current;
if (!state) return;
const width = Math.max(
minColumnWidth,
state.startWidth + (e.clientX - state.startX),
);
setWidths((prev) => ({ ...prev, [state.key]: width }));
},
[minColumnWidth],
);
const endResize = useCallback(
(e: ReactPointerEvent) => {
const state = resizeRef.current;
resizeRef.current = null;
releasePointer(e.currentTarget, e.pointerId);
if (state) {
onColumnResize?.(state.key, widths[state.key] ?? state.startWidth);
}
},
[onColumnResize, widths],
);
return { widths, startResize, moveResize, endResize };
}
import { useCallback, useMemo, useState } from "react";
import type { SortState, TableColumn, TableRow } from "./types";
import { readSortValue } from "./utils";
export function useColumnSort<T>({
rows,
columns,
sort: sortProp,
defaultSort = null,
onSortChange,
}: {
rows: TableRow<T>[];
columns: TableColumn<T>[];
sort?: SortState | null;
defaultSort?: SortState | null;
onSortChange?: (sort: SortState | null) => void;
}) {
const [internalSort, setInternalSort] = useState<SortState | null>(
defaultSort,
);
const sort = sortProp !== undefined ? sortProp : internalSort;
const commit = useCallback(
(next: SortState | null) => {
if (sortProp === undefined) setInternalSort(next);
onSortChange?.(next);
},
[sortProp, onSortChange],
);
const toggleSort = useCallback(
(key: string) => {
if (!sort || sort.key !== key) {
commit({ key, direction: "asc" });
} else if (sort.direction === "asc") {
commit({ key, direction: "desc" });
} else {
commit(null);
}
},
[sort, commit],
);
const sortedRows = useMemo(() => {
if (!sort) return rows;
const column = columns.find((c) => c.key === sort.key);
if (!column) return rows;
const copy = [...rows];
copy.sort((a, b) => {
const av = readSortValue(a.row, column);
const bv = readSortValue(b.row, column);
let cmp: number;
if (typeof av === "number" && typeof bv === "number") {
cmp = av - bv;
} else {
cmp = String(av).localeCompare(String(bv));
}
return sort.direction === "asc" ? cmp : -cmp;
});
return copy;
}, [rows, sort, columns]);
return { sort, sortedRows, toggleSort };
}
import { useCallback, useMemo, useState } from "react";
import type { TableRow } from "./types";
export function useRowSelection<T>({
sortedRows,
selectedRowIds,
defaultSelectedRowIds,
onSelectionChange,
}: {
sortedRows: TableRow<T>[];
selectedRowIds?: string[];
defaultSelectedRowIds?: string[];
onSelectionChange?: (ids: string[]) => void;
}) {
const [internalSelected, setInternalSelected] = useState<Set<string>>(
() => new Set(defaultSelectedRowIds),
);
const selected = useMemo(
() =>
selectedRowIds !== undefined
? new Set(selectedRowIds)
: internalSelected,
[selectedRowIds, internalSelected],
);
const commit = useCallback(
(next: Set<string>) => {
if (selectedRowIds === undefined) setInternalSelected(next);
onSelectionChange?.([...next]);
},
[selectedRowIds, onSelectionChange],
);
const allSelected =
sortedRows.length > 0 && sortedRows.every((r) => selected.has(r.id));
const someSelected = sortedRows.some((r) => selected.has(r.id));
const toggleAll = useCallback(() => {
const next = new Set(selected);
if (allSelected) {
for (const r of sortedRows) next.delete(r.id);
} else {
for (const r of sortedRows) next.add(r.id);
}
commit(next);
}, [allSelected, sortedRows, selected, commit]);
const toggleRow = useCallback(
(id: string) => {
const next = new Set(selected);
if (next.has(id)) next.delete(id);
else next.add(id);
commit(next);
},
[selected, commit],
);
return { selected, allSelected, someSelected, toggleAll, toggleRow };
}
import type { ReactNode } from "react";
import type { TableColumn } from "./types";
export const CHECKBOX_PX = 48;
export const CHECKBOX_WIDTH = `${CHECKBOX_PX}px`;
/** Highlights the top edge of the active column's header cell. */
export const COLUMN_ACTIVE_SHADOW = "inset 0 1px 0 var(--color-primary)";
export function alignFlex(align: TableColumn<unknown>["align"]) {
if (align === "right") return "justify-end";
if (align === "center") return "justify-center";
return "justify-start";
}
export function alignText(align: TableColumn<unknown>["align"]) {
if (align === "right") return "text-right";
if (align === "center") return "text-center";
return "text-left";
}
export function readCell<T>(row: T, column: TableColumn<T>): ReactNode {
if (column.cell) return column.cell(row);
return (row as Record<string, ReactNode>)[column.key];
}
export function readSortValue<T>(
row: T,
column: TableColumn<T>,
): string | number {
if (column.sortValue) return column.sortValue(row);
return (row as Record<string, string | number>)[column.key];
}
"use client";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useId } from "react";
import { EASE_OUT, SPRING_PRESS } from "@/lib/ease";
import { cn } from "@/lib/utils";
const CHECK_PATH = "M5 13l4 4L19 7";
const INDETERMINATE_PATH = "M6 12h12";
export interface CheckboxProps {
checked: boolean;
onCheckedChange: (checked: boolean) => void;
disabled?: boolean;
indeterminate?: boolean;
label?: string;
className?: string;
id?: string;
"aria-label"?: string;
/** Associates an external message (e.g. a form error) with the control. */
"aria-describedby"?: string;
}
export function Checkbox({
checked,
onCheckedChange,
disabled,
indeterminate,
label,
className,
id: idProp,
"aria-label": ariaLabel,
"aria-describedby": ariaDescribedBy,
}: CheckboxProps) {
const autoId = useId();
const id = idProp ?? autoId;
const reduce = useReducedMotion();
const showMark = checked || indeterminate;
const path = indeterminate ? INDETERMINATE_PATH : CHECK_PATH;
return (
<label
htmlFor={id}
className={cn(
"inline-flex items-center gap-3",
disabled ? "cursor-not-allowed" : "cursor-pointer",
className,
)}
>
<motion.button
id={id}
type="button"
role="checkbox"
aria-checked={indeterminate ? "mixed" : checked}
aria-label={ariaLabel}
aria-describedby={ariaDescribedBy}
disabled={disabled}
onClick={() => !disabled && onCheckedChange(!checked)}
whileTap={reduce || disabled ? undefined : { scale: 0.92 }}
transition={SPRING_PRESS}
data-state={
checked ? "checked" : indeterminate ? "indeterminate" : "unchecked"
}
className={cn(
"inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-md border-2 outline-none transition-colors duration-200",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"disabled:cursor-not-allowed disabled:opacity-60",
showMark
? "border-primary bg-primary text-primary-foreground"
: "border-muted-foreground/50 bg-background hover:border-muted-foreground",
)}
>
<AnimatePresence initial={false}>
{showMark ? (
<motion.svg
key={indeterminate ? "indeterminate" : "checked"}
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={3}
strokeLinecap="round"
strokeLinejoin="round"
initial={reduce ? { opacity: 1 } : { opacity: 0, scale: 0.5 }}
animate={reduce ? { opacity: 1 } : { opacity: 1, scale: 1 }}
exit={
reduce
? { opacity: 0 }
: { opacity: 0, scale: 0.5, filter: "blur(4px)" }
}
transition={
reduce ? { duration: 0 } : { duration: 0.16, ease: EASE_OUT }
}
aria-hidden
>
<title>{indeterminate ? "Partially selected" : "Selected"}</title>
<motion.path
d={path}
initial={reduce ? { pathLength: 1 } : { pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={
reduce
? { duration: 0 }
: {
duration: indeterminate ? 0.2 : 0.3,
ease: EASE_OUT,
delay: 0.04,
}
}
/>
</motion.svg>
) : null}
</AnimatePresence>
</motion.button>
{label ? (
<span className={cn("select-none text-sm text-foreground", disabled && "opacity-60")}>
{label}
</span>
) : null}
</label>
);
}
"use client";
import { motion, useReducedMotion } from "motion/react";
import { type ReactNode, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { SPRING_PANEL } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type TableMenuItem = {
label: string;
icon?: ReactNode;
onSelect: () => void;
destructive?: boolean;
};
const MENU_WIDTH = 188;
export function TableMenu({
items,
ariaLabel,
trigger,
triggerClassName,
}: {
items: TableMenuItem[];
ariaLabel: string;
trigger: ReactNode;
triggerClassName?: string;
}) {
const reduce = useReducedMotion();
const triggerRef = useRef<HTMLButtonElement>(null);
const [coords, setCoords] = useState<{ top: number; left: number } | null>(
null,
);
const open = coords !== null;
useEffect(() => {
if (!open) return;
const close = () => setCoords(null);
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") close();
};
// Close on any scroll (the trigger moves) or resize; fixed coords go stale.
window.addEventListener("scroll", close, true);
window.addEventListener("resize", close);
window.addEventListener("keydown", onKey);
return () => {
window.removeEventListener("scroll", close, true);
window.removeEventListener("resize", close);
window.removeEventListener("keydown", onKey);
};
}, [open]);
const toggle = () => {
if (open) {
setCoords(null);
return;
}
const el = triggerRef.current;
if (!el) return;
const r = el.getBoundingClientRect();
setCoords({
top: r.bottom + 4,
left: Math.max(8, r.right - MENU_WIDTH),
});
};
return (
<>
<button
ref={triggerRef}
type="button"
aria-label={ariaLabel}
aria-haspopup="menu"
aria-expanded={open}
onClick={(e) => {
e.stopPropagation();
toggle();
}}
className={triggerClassName}
>
{trigger}
</button>
{open && typeof document !== "undefined"
? createPortal(
<>
<div
className="fixed inset-0 z-40"
onPointerDown={() => setCoords(null)}
/>
<motion.div
role="menu"
className="fixed z-50 overflow-hidden rounded-xl border border-border bg-background p-1 shadow-xl"
style={{ top: coords.top, left: coords.left, width: MENU_WIDTH }}
initial={
reduce ? { opacity: 0 } : { opacity: 0, scale: 0.96, y: -4 }
}
animate={
reduce ? { opacity: 1 } : { opacity: 1, scale: 1, y: 0 }
}
transition={reduce ? { duration: 0 } : SPRING_PANEL}
>
{items.map((item) => (
<button
key={item.label}
type="button"
role="menuitem"
onClick={() => {
setCoords(null);
item.onSelect();
}}
className={cn(
"flex w-full items-center gap-2.5 rounded-lg px-2.5 py-1.5 text-left text-sm transition-colors [&_svg]:h-4 [&_svg]:w-4",
item.destructive
? "text-rose-500 hover:bg-rose-500/10"
: "text-foreground hover:bg-muted",
)}
>
{item.icon}
{item.label}
</button>
))}
</motion.div>
</>,
document.body,
)
: null}
</>
);
}
"use client";
import { animate, motion, MotionConfig, useReducedMotion } from "motion/react";
import { useEffect, useId, useRef, useState } from "react";
import { cn } from "@/lib/utils";
// Heavy, deliberate thumb — high mass keeps the travel weighty without wobble.
const THUMB_SPRING = { type: "spring", stiffness: 800, damping: 80, mass: 4 } as const;
export interface SwitchProps {
checked: boolean;
onCheckedChange: (checked: boolean) => void;
disabled?: boolean;
label?: string;
ariaLabel?: string;
className?: string;
}
export function Switch({
checked,
onCheckedChange,
disabled,
label,
ariaLabel,
className,
}: SwitchProps) {
const id = useId();
const thumbRef = useRef<HTMLDivElement>(null);
const reduce = useReducedMotion();
const [isPressed, setIsPressed] = useState(false);
const [isPointer, setIsPointer] = useState(false);
// Disabled shake feedback when pressed.
useEffect(() => {
if (!thumbRef.current || reduce) return;
if (disabled && isPressed) {
animate(
thumbRef.current,
{ x: [0, -2, 2, -1, 0] },
{ delay: 0.2, duration: 0.6 },
);
}
}, [disabled, isPressed, reduce]);
const squish = !disabled && isPointer && isPressed && !reduce;
return (
<MotionConfig transition={reduce ? { duration: 0 } : THUMB_SPRING}>
<span className={cn("inline-flex items-center gap-3", className)}>
<motion.button
id={id}
type="button"
role="switch"
aria-checked={checked}
aria-label={ariaLabel}
disabled={disabled}
onClick={() => !disabled && onCheckedChange(!checked)}
onPointerDown={(e) => {
setIsPressed(true);
setIsPointer(e.type.startsWith("pointer"));
}}
onPointerUp={() => setIsPressed(false)}
onPointerLeave={() => setIsPressed(false)}
initial={false}
data-state={checked ? "checked" : "unchecked"}
className={cn(
"group peer inline-flex h-7 w-12 shrink-0 cursor-pointer items-center px-1 rounded-full outline-none transition-colors duration-200",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"disabled:cursor-not-allowed disabled:opacity-60",
checked ? "justify-end bg-primary" : "justify-start bg-muted-foreground/60",
)}
>
<motion.div
ref={thumbRef}
layout
animate={{ scale: squish ? 0.9 : 1 }}
className="pointer-events-none block h-5 w-5 rounded-full bg-background shadow-md"
>
{/* Stretch toward the destination while active. */}
<div
className={cn(
"size-5",
squish && (checked ? "ml-1" : "mr-1"),
)}
/>
</motion.div>
</motion.button>
{label ? (
<label htmlFor={id} className="cursor-pointer text-sm text-foreground">
{label}
</label>
) : null}
</span>
</MotionConfig>
);
}
Async Table
index.tsxLoads pages on demand — skeleton rows on first load, then infinite scroll via onEndReached as the virtualized list nears the bottom.
Name | Email | Role | Status | MRR |
|---|---|---|---|---|
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Table, type TableColumn } from "@/components/motion/table";
import { cn } from "@/lib/utils";
type Person = {
id: string;
name: string;
email: string;
role: string;
status: "active" | "invited" | "suspended";
mrr: number;
};
const FIRST = ["Ava", "Leo", "Mia", "Kai", "Zoe", "Eli", "Noa", "Ren", "Ivy", "Jude"];
const LAST = ["Cole", "Frost", "Vale", "Reyes", "Okafor", "Sato", "Lund", "Marsh", "Bose", "Quinn"];
const ROLES = ["Owner", "Admin", "Member", "Viewer"];
const STATUSES: Person["status"][] = ["active", "invited", "suspended"];
const PAGE_SIZE = 20;
const MAX_PAGES = 8;
function buildPage(page: number): Person[] {
const out: Person[] = [];
const start = page * PAGE_SIZE;
for (let n = start; n < start + PAGE_SIZE; n++) {
const first = FIRST[n % FIRST.length];
const last = LAST[(n * 7) % LAST.length];
out.push({
id: String(n),
name: `${first} ${last}`,
email: `${first.toLowerCase()}.${last.toLowerCase()}${n}@beui.dev`,
role: ROLES[(n * 3) % ROLES.length],
status: STATUSES[(n * 5) % STATUSES.length],
mrr: 12 + ((n * 37) % 488),
});
}
return out;
}
const STATUS_STYLES: Record<Person["status"], string> = {
active: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400",
invited: "bg-amber-500/10 text-amber-600 dark:text-amber-400",
suspended: "bg-rose-500/10 text-rose-600 dark:text-rose-400",
};
export function TableAsyncPreview() {
const [rows, setRows] = useState<Person[]>([]);
const [loading, setLoading] = useState(true);
const pageRef = useRef(0);
const loadingRef = useRef(false);
const loadMore = useCallback(() => {
if (loadingRef.current || pageRef.current >= MAX_PAGES) return;
loadingRef.current = true;
setLoading(true);
// Simulate a network request.
setTimeout(() => {
const page = pageRef.current;
setRows((prev) => [...prev, ...buildPage(page)]);
pageRef.current = page + 1;
loadingRef.current = false;
setLoading(false);
}, 700);
}, []);
// biome-ignore lint/correctness/useExhaustiveDependencies: run once on mount
useEffect(() => {
loadMore();
}, []);
const columns = useMemo<TableColumn<Person>[]>(
() => [
{
key: "name",
header: "Name",
cell: (r) => <span className="font-medium">{r.name}</span>,
},
{ key: "email", header: "Email", width: "220px" },
{ key: "role", header: "Role", width: "110px" },
{
key: "status",
header: "Status",
width: "120px",
cell: (r) => (
<span
className={cn(
"rounded-full px-2 py-0.5 font-medium text-xs capitalize",
STATUS_STYLES[r.status],
)}
>
{r.status}
</span>
),
},
{
key: "mrr",
header: "MRR",
align: "right",
width: "100px",
cell: (r) => <span className="tabular-nums">${r.mrr.toLocaleString()}</span>,
},
],
[],
);
const done = pageRef.current >= MAX_PAGES;
return (
<div className="flex w-full justify-center p-4">
<div className="flex w-full flex-col gap-2">
<div className="flex items-center justify-between px-1 text-muted-foreground text-xs">
<span>{rows.length.toLocaleString()} loaded</span>
<span>{loading ? "Loading…" : done ? "All loaded" : "Scroll for more"}</span>
</div>
<Table
data={rows}
columns={columns}
getRowId={(row) => row.id}
height={420}
rowHeight={52}
onEndReached={loadMore}
loading={loading}
className="rounded-2xl"
/>
</div>
</div>
);
}
"use client";
// beui.dev/components/motion/table
import { useVirtualizer } from "@tanstack/react-virtual";
import { useReducedMotion } from "motion/react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Checkbox } from "@/components/motion/checkbox";
import { cn } from "@/lib/utils";
import { EditableCell } from "./editable-cell";
import { RowHandle } from "./row-handle";
import { SkeletonRows } from "./skeleton-rows";
import { TableHeader } from "./table-header";
import type { HeaderCellRefs, TableProps } from "./types";
import { useColumnReorder } from "./use-column-reorder";
import { useColumnResize } from "./use-column-resize";
import { useColumnSort } from "./use-column-sort";
import { useRowSelection } from "./use-row-selection";
import { alignText, CHECKBOX_PX, CHECKBOX_WIDTH, readCell } from "./utils";
export type {
SortDirection,
SortState,
TableColumn,
TableProps,
} from "./types";
/**
* Narrowest a column of bare inputs may be floored to and still show a value:
* the cell's own `px-4` eats 32 of it.
*/
const INPUT_COLUMN_WIDTH = 120;
/** The root font size Tailwind's rem scale assumes, and the pre-measure guess. */
const DEFAULT_ROOT_FONT_SIZE = 16;
/**
* What one `rem` is worth here, in px. The default until the first client
* layout, so the server and the hydrating client emit the same floor; measured
* once after that, because a document that sets its own `html { font-size }`
* lays a rem column out against that size and a floor computed from 16 would
* fall short by the same factor.
*/
function useRootFontSize() {
const [size, setSize] = useState(DEFAULT_ROOT_FONT_SIZE);
useEffect(() => {
const measured = Number.parseFloat(
getComputedStyle(document.documentElement).fontSize,
);
if (measured > 0) setSize(measured);
}, []);
return size;
}
/**
* The absolute width a column declared, in px, or null when it declared a share
* of the remainder instead (`fr`, `%`, `auto`, `calc()`, nothing at all) — those
* are worth whatever is left over, which is not a width this can add up.
*/
function resolveColumnWidth(
width: string | undefined,
rootFontSize: number,
): number | null {
if (!width) return null;
const value = Number.parseFloat(width);
if (!Number.isFinite(value)) return null;
if (width.endsWith("px")) return value;
// rem is the other absolute length the repo writes.
if (width.endsWith("rem")) return value * rootFontSize;
return null;
}
export function Table<T>({
data,
columns,
getRowId,
selectable = false,
selectedRowIds,
defaultSelectedRowIds,
onSelectionChange,
sort: sortProp,
defaultSort = null,
onSortChange,
resizable = false,
minColumnWidth = 64,
onColumnResize,
reorderable = false,
onColumnOrderChange,
onCellEdit,
onColumnRename,
onInsertRow,
onDeleteRow,
onInsertColumn,
onDeleteColumn,
rowHeight = 48,
height = 440,
overscan = 10,
onEndReached,
loading = false,
skeletonRows = 3,
emptyState = "No data",
className,
}: TableProps<T>) {
const reduce = useReducedMotion();
const scrollRef = useRef<HTMLDivElement>(null);
const thRefs: HeaderCellRefs = useRef<
Record<string, HTMLTableCellElement | null>
>({});
const rows = useMemo(
() =>
data.map((row, index) => ({
row,
id: getRowId ? getRowId(row, index) : String(index),
})),
[data, getRowId],
);
const {
orderedColumns,
dragKey,
dropIndex,
startReorder,
moveReorder,
endReorder,
} = useColumnReorder({ columns, thRefs, onColumnOrderChange });
const { sort, sortedRows, toggleSort } = useColumnSort({
rows,
columns,
sort: sortProp,
defaultSort,
onSortChange,
});
const { widths, startResize, moveResize, endResize } = useColumnResize({
orderedColumns,
thRefs,
minColumnWidth,
onColumnResize,
});
const { selected, allSelected, someSelected, toggleAll, toggleRow } =
useRowSelection({
sortedRows,
selectedRowIds,
defaultSelectedRowIds,
onSelectionChange,
});
const virtualizer = useVirtualizer({
count: sortedRows.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => rowHeight,
overscan,
});
const virtualItems = virtualizer.getVirtualItems();
const totalSize = virtualizer.getTotalSize();
const paddingTop = virtualItems.length > 0 ? virtualItems[0].start : 0;
const paddingBottom =
virtualItems.length > 0
? totalSize - virtualItems[virtualItems.length - 1].end
: 0;
const hasRowMenu = !!(onInsertRow || onDeleteRow);
const hasColumnMenu = !!(onInsertColumn || onDeleteColumn);
// Only shrink-wrap (w-max) once every column has an explicit resized width;
// otherwise stay fill-width so a flexible column can't size to cell content.
const sized =
orderedColumns.length > 0 &&
orderedColumns.every((c) => widths[c.key] != null);
const rootFontSize = useRootFontSize();
// In a container narrower than the columns, `table-layout: fixed` shrinks
// every column toward zero instead of scrolling. Floor the table at what the
// columns actually asked for — an absolute declared width where there is one,
// and for the ones sharing the remainder whatever content they can fall back
// on — then let the viewport scroll past it.
const minTableWidth = useMemo(() => {
// A column whose cells render bare inputs has no content for the fallback
// to measure, which is exactly the column that collapses; a renamable
// header is an input too, and in a fixed layout the header row is what
// sizes the column.
const inputOnly = (column: (typeof orderedColumns)[number]) =>
Boolean(onColumnRename) || (!column.cell && Boolean(column.editable));
const total = orderedColumns.reduce((sum, column) => {
const resized = widths[column.key];
if (resized != null) return sum + resized;
const declared = resolveColumnWidth(column.width, rootFontSize);
if (declared != null) return sum + declared;
return (
sum +
(inputOnly(column)
? Math.max(minColumnWidth, INPUT_COLUMN_WIDTH)
: minColumnWidth)
);
}, selectable ? CHECKBOX_PX : 0);
return Math.round(total);
}, [
minColumnWidth,
onColumnRename,
orderedColumns,
rootFontSize,
selectable,
widths,
]);
// Infinite scroll: fire onEndReached once per near-bottom dwell, paused while
// loading; the guard resets when the load completes.
const endReachedRef = useRef(false);
useEffect(() => {
if (!loading) endReachedRef.current = false;
}, [loading]);
const handleScroll = useCallback(() => {
const el = scrollRef.current;
if (!el || !onEndReached || loading || endReachedRef.current) return;
if (el.scrollHeight - el.scrollTop - el.clientHeight < rowHeight * 4) {
endReachedRef.current = true;
onEndReached();
}
}, [onEndReached, loading, rowHeight]);
const [activeColumn, setActiveColumn] = useState<string | null>(null);
// Small delay on leave so the pointer can cross the gap from the header cell
// to the portal handle without the column deactivating.
const deactivateTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const activateColumn = useCallback((key: string) => {
if (deactivateTimer.current) clearTimeout(deactivateTimer.current);
deactivateTimer.current = null;
setActiveColumn(key);
}, []);
const deactivateColumn = useCallback(() => {
if (deactivateTimer.current) clearTimeout(deactivateTimer.current);
deactivateTimer.current = setTimeout(() => setActiveColumn(null), 100);
}, []);
const rowRefs = useRef<Record<string, HTMLTableRowElement | null>>({});
const [activeRow, setActiveRow] = useState<{ id: string; index: number } | null>(
null,
);
const rowTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const activateRow = useCallback((id: string, index: number) => {
if (rowTimer.current) clearTimeout(rowTimer.current);
rowTimer.current = null;
setActiveRow({ id, index });
}, []);
const deactivateRow = useCallback(() => {
if (rowTimer.current) clearTimeout(rowTimer.current);
rowTimer.current = setTimeout(() => setActiveRow(null), 100);
}, []);
const activeRowEl = activeRow ? rowRefs.current[activeRow.id] : null;
// Real columns + checkbox; the trailing spacer adds one more in colSpans.
const leadColumns = columns.length + (selectable ? 1 : 0);
return (
<div
className={cn(
"w-full overflow-hidden border border-border bg-background text-sm",
className,
)}
>
<div
ref={scrollRef}
onScroll={handleScroll}
className="overflow-auto"
style={{ height }}
>
<table
className={cn("border-collapse", sized ? "w-max" : undefined)}
style={{
tableLayout: "fixed",
minWidth: `max(100%, ${minTableWidth}px)`,
}}
>
<colgroup>
{selectable ? <col style={{ width: CHECKBOX_WIDTH }} /> : null}
{orderedColumns.map((column) => {
const override = widths[column.key];
const width = override ? `${override}px` : column.width;
return (
<col key={column.key} style={width ? { width } : undefined} />
);
})}
{/* Empty filler owns the leftover space — no gap, content unpinned. */}
<col />
</colgroup>
<TableHeader
columns={orderedColumns}
rowHeight={rowHeight}
reduce={!!reduce}
thRefs={thRefs}
selectable={selectable}
allSelected={allSelected}
someSelected={someSelected}
onToggleAll={toggleAll}
sort={sort}
onToggleSort={toggleSort}
resizable={resizable}
onResizeStart={startResize}
onResizeMove={moveResize}
onResizeEnd={endResize}
reorderable={reorderable}
dragKey={dragKey}
dropIndex={dropIndex}
onReorderStart={startReorder}
onReorderMove={moveReorder}
onReorderEnd={endReorder}
onInsertColumn={onInsertColumn}
onDeleteColumn={onDeleteColumn}
onColumnRename={onColumnRename}
activeColumn={hasColumnMenu ? activeColumn : null}
onColumnActivate={hasColumnMenu ? activateColumn : undefined}
onColumnDeactivate={hasColumnMenu ? deactivateColumn : undefined}
/>
<tbody>
{sortedRows.length === 0 ? (
loading ? (
<SkeletonRows
count={Math.max(1, Math.ceil(height / rowHeight))}
columns={orderedColumns}
selectable={selectable}
rowHeight={rowHeight}
/>
) : (
<tr>
<td
colSpan={leadColumns + 1}
className="p-10 text-center text-muted-foreground"
>
{emptyState}
</td>
</tr>
)
) : (
<>
{paddingTop > 0 ? (
<tr aria-hidden style={{ height: paddingTop }}>
<td colSpan={leadColumns + 1} />
</tr>
) : null}
{virtualItems.map((vItem) => {
const entry = sortedRows[vItem.index];
const isSelected = selected.has(entry.id);
return (
<tr
key={entry.id}
ref={(el) => {
rowRefs.current[entry.id] = el;
}}
data-selected={isSelected}
style={{ height: rowHeight }}
onPointerEnter={
hasRowMenu
? () => activateRow(entry.id, vItem.index)
: undefined
}
onPointerLeave={hasRowMenu ? deactivateRow : undefined}
className={cn(
"border-border/60 border-b transition-colors",
"data-[selected=true]:bg-primary/5",
"hover:bg-muted/50",
)}
>
{selectable ? (
<td className="text-center">
<div className="flex items-center justify-center">
<Checkbox
checked={isSelected}
onCheckedChange={() => toggleRow(entry.id)}
aria-label={`Select row ${vItem.index + 1}`}
/>
</div>
</td>
) : null}
{orderedColumns.map((column) => (
<td
key={column.key}
className={cn(
"truncate px-4 text-foreground",
alignText(column.align),
)}
>
{!column.cell && column.editable ? (
<EditableCell
value={String(readCell(entry.row, column) ?? "")}
label={`${column.key} for row ${vItem.index + 1}`}
onChange={(next) =>
onCellEdit?.(entry.id, column.key, next)
}
/>
) : (
readCell(entry.row, column)
)}
</td>
))}
<td aria-hidden />
</tr>
);
})}
{paddingBottom > 0 ? (
<tr aria-hidden style={{ height: paddingBottom }}>
<td colSpan={leadColumns + 1} />
</tr>
) : null}
{loading ? (
<SkeletonRows
count={skeletonRows}
columns={orderedColumns}
selectable={selectable}
rowHeight={rowHeight}
/>
) : null}
</>
)}
</tbody>
</table>
</div>
{hasRowMenu && activeRow ? (
<RowHandle
rowEl={activeRowEl}
id={activeRow.id}
index={activeRow.index}
onInsertRow={onInsertRow}
onDeleteRow={onDeleteRow}
onEnter={() => activateRow(activeRow.id, activeRow.index)}
onLeave={deactivateRow}
/>
) : null}
</div>
);
}
Install
shadcn init? You are set. Theme setupInstall dependencies
npm i @tanstack/react-virtual clsx lucide-react motion tailwind-mergeAdd util files
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
// Shared touch primitives. iOS and iPadOS run their own gestures on top of the
// page — the long-press selection callout and the selection it drags in with
// it — and they win: once the platform claims a touch it cancels ours
// mid-gesture, so a press-and-hold or a drag simply dies. Surfaces that own
// their gesture have to opt out.
//
// What the two classes below cover, precisely:
// - `-webkit-touch-callout: none` stops iOS's long-press callout. WebKit-only:
// it is not a property other engines have, so it is inert everywhere else.
// - `user-select: none` stops the long-press selection on every engine,
// Android included, and stops a drag from painting a selection under the
// cursor. It is inherited, so it reaches every descendant — which is why the
// two classes differ only in whether they apply it unconditionally.
// What neither covers:
// - Chrome for Android's long-press menu on a link or an image. No CSS
// suppresses it; a gesture surface that wraps one needs its own
// `onContextMenu` with `preventDefault()`.
// - The native drag of an `<img>` or `<a>` descendant. `-webkit-user-drag` is
// not inherited and plain divs and buttons are not drag sources, so setting
// it on the surface does nothing — the child itself needs `draggable={false}`.
/**
* Classes for a surface that *is* the control: a thumb, a drum, a stage, a
* handle, a hold button. Selection is suppressed on every input, because a
* drag that highlights the control's own label is wrong on a mouse too.
* Compose with `touch-none` when the surface also owns the scroll axis — leave
* it off when the page must still scroll from there.
*/
export const TOUCH_GESTURE_CLASS = "select-none [-webkit-touch-callout:none]";
/**
* The same opt-out for a gesture surface that wraps content the consumer owns:
* a scroller, a context-menu trigger, a sheet header, a list row. Selection is
* suppressed only where the platform runs its own press gestures — a coarse
* pointer — so a mouse user can still select and copy that content. If the
* gesture itself would paint a selection under the cursor, add `select-none`
* for the duration of the gesture rather than reaching for
* `TOUCH_GESTURE_CLASS`.
*
* `pointer: coarse` describes the *primary* pointer and nothing else, so a
* hybrid machine reads it wrong in both directions: a tablet with a mouse
* plugged in keeps touch as primary and loses mouse selection, and a laptop
* with a touchscreen keeps the mouse as primary and leaves selection live
* under a finger. No media query can answer per interaction — the query is
* about the device, and the question is about the gesture in progress. The
* default stays here because it is right on the machines that are one thing or
* the other, and losing a selection is a nuisance; where the miss costs a
* *gesture* instead, the surface pairs it with `holdSelection` on the press.
*/
export const TOUCH_GESTURE_CONTENT_CLASS =
"[-webkit-touch-callout:none] pointer-coarse:select-none";
/**
* Suppress selection on `element` for as long as a gesture is running on it,
* whatever the primary pointer of the machine happens to be. Returns the
* release. Inline, so it wins over the class above and is gone again the
* moment the gesture ends.
*
* For the press gestures a native selection would otherwise steal — a
* long-press that opens a menu. Elsewhere prefer the classes: a surface that
* takes selection away for the whole session is a surface whose text nobody
* can copy.
*/
export function holdSelection(element: HTMLElement) {
element.style.setProperty("user-select", "none");
element.style.setProperty("-webkit-user-select", "none");
return () => {
element.style.removeProperty("user-select");
element.style.removeProperty("-webkit-user-select");
};
}
/**
* Pointer capture, best effort. WebKit throws `NotFoundError` when the pointer
* is already gone by the time the handler runs — routine on iOS, where the
* system can claim the touch first — and an uncaught throw takes the rest of
* the handler, the gesture included, down with it. Touch pointers carry
* implicit capture anyway, so losing it is never fatal.
*/
export function capturePointer(element: Element, pointerId: number) {
try {
element.setPointerCapture(pointerId);
} catch {
// Pointer is no longer active — implicit capture still applies on touch.
}
}
/** Release a capture taken with `capturePointer`, ignoring a stale pointer. */
export function releasePointer(element: Element, pointerId: number) {
try {
if (element.hasPointerCapture(pointerId)) {
element.releasePointerCapture(pointerId);
}
} catch {
// Capture was already dropped by the browser.
}
}
/**
* Whether this event came from a pointer that is *hovering*: not a touch, and
* not currently pressed. Which input the user is holding right now is not
* something a device capability can answer — a touchscreen laptop hovers and
* taps, and iPadOS reports a fine hovering pointer for a finger — so both
* paths stay live and each handler branches on the event it was given.
*
* A pen resting on the glass is making contact, not hovering: `buttons` is the
* tell, and it sends a pen tap down the same route a finger takes.
*
* This answers what an *enter* asks. A leave is the other half of a pair and
* has to be read against the enter that started it — `useHoverGesture` in
* `lib/hooks/use-hover-gesture` does that, and hover surfaces should use it
* rather than asking this question twice.
*/
export const isHoveringPointer = (event: {
pointerType: string;
buttons: number;
}) => event.pointerType !== "touch" && event.buttons === 0;
Copy the source code
"use client";
// beui.dev/components/motion/table
import { useVirtualizer } from "@tanstack/react-virtual";
import { useReducedMotion } from "motion/react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Checkbox } from "@/components/motion/checkbox";
import { cn } from "@/lib/utils";
import { EditableCell } from "./editable-cell";
import { RowHandle } from "./row-handle";
import { SkeletonRows } from "./skeleton-rows";
import { TableHeader } from "./table-header";
import type { HeaderCellRefs, TableProps } from "./types";
import { useColumnReorder } from "./use-column-reorder";
import { useColumnResize } from "./use-column-resize";
import { useColumnSort } from "./use-column-sort";
import { useRowSelection } from "./use-row-selection";
import { alignText, CHECKBOX_PX, CHECKBOX_WIDTH, readCell } from "./utils";
export type {
SortDirection,
SortState,
TableColumn,
TableProps,
} from "./types";
/**
* Narrowest a column of bare inputs may be floored to and still show a value:
* the cell's own `px-4` eats 32 of it.
*/
const INPUT_COLUMN_WIDTH = 120;
/** The root font size Tailwind's rem scale assumes, and the pre-measure guess. */
const DEFAULT_ROOT_FONT_SIZE = 16;
/**
* What one `rem` is worth here, in px. The default until the first client
* layout, so the server and the hydrating client emit the same floor; measured
* once after that, because a document that sets its own `html { font-size }`
* lays a rem column out against that size and a floor computed from 16 would
* fall short by the same factor.
*/
function useRootFontSize() {
const [size, setSize] = useState(DEFAULT_ROOT_FONT_SIZE);
useEffect(() => {
const measured = Number.parseFloat(
getComputedStyle(document.documentElement).fontSize,
);
if (measured > 0) setSize(measured);
}, []);
return size;
}
/**
* The absolute width a column declared, in px, or null when it declared a share
* of the remainder instead (`fr`, `%`, `auto`, `calc()`, nothing at all) — those
* are worth whatever is left over, which is not a width this can add up.
*/
function resolveColumnWidth(
width: string | undefined,
rootFontSize: number,
): number | null {
if (!width) return null;
const value = Number.parseFloat(width);
if (!Number.isFinite(value)) return null;
if (width.endsWith("px")) return value;
// rem is the other absolute length the repo writes.
if (width.endsWith("rem")) return value * rootFontSize;
return null;
}
export function Table<T>({
data,
columns,
getRowId,
selectable = false,
selectedRowIds,
defaultSelectedRowIds,
onSelectionChange,
sort: sortProp,
defaultSort = null,
onSortChange,
resizable = false,
minColumnWidth = 64,
onColumnResize,
reorderable = false,
onColumnOrderChange,
onCellEdit,
onColumnRename,
onInsertRow,
onDeleteRow,
onInsertColumn,
onDeleteColumn,
rowHeight = 48,
height = 440,
overscan = 10,
onEndReached,
loading = false,
skeletonRows = 3,
emptyState = "No data",
className,
}: TableProps<T>) {
const reduce = useReducedMotion();
const scrollRef = useRef<HTMLDivElement>(null);
const thRefs: HeaderCellRefs = useRef<
Record<string, HTMLTableCellElement | null>
>({});
const rows = useMemo(
() =>
data.map((row, index) => ({
row,
id: getRowId ? getRowId(row, index) : String(index),
})),
[data, getRowId],
);
const {
orderedColumns,
dragKey,
dropIndex,
startReorder,
moveReorder,
endReorder,
} = useColumnReorder({ columns, thRefs, onColumnOrderChange });
const { sort, sortedRows, toggleSort } = useColumnSort({
rows,
columns,
sort: sortProp,
defaultSort,
onSortChange,
});
const { widths, startResize, moveResize, endResize } = useColumnResize({
orderedColumns,
thRefs,
minColumnWidth,
onColumnResize,
});
const { selected, allSelected, someSelected, toggleAll, toggleRow } =
useRowSelection({
sortedRows,
selectedRowIds,
defaultSelectedRowIds,
onSelectionChange,
});
const virtualizer = useVirtualizer({
count: sortedRows.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => rowHeight,
overscan,
});
const virtualItems = virtualizer.getVirtualItems();
const totalSize = virtualizer.getTotalSize();
const paddingTop = virtualItems.length > 0 ? virtualItems[0].start : 0;
const paddingBottom =
virtualItems.length > 0
? totalSize - virtualItems[virtualItems.length - 1].end
: 0;
const hasRowMenu = !!(onInsertRow || onDeleteRow);
const hasColumnMenu = !!(onInsertColumn || onDeleteColumn);
// Only shrink-wrap (w-max) once every column has an explicit resized width;
// otherwise stay fill-width so a flexible column can't size to cell content.
const sized =
orderedColumns.length > 0 &&
orderedColumns.every((c) => widths[c.key] != null);
const rootFontSize = useRootFontSize();
// In a container narrower than the columns, `table-layout: fixed` shrinks
// every column toward zero instead of scrolling. Floor the table at what the
// columns actually asked for — an absolute declared width where there is one,
// and for the ones sharing the remainder whatever content they can fall back
// on — then let the viewport scroll past it.
const minTableWidth = useMemo(() => {
// A column whose cells render bare inputs has no content for the fallback
// to measure, which is exactly the column that collapses; a renamable
// header is an input too, and in a fixed layout the header row is what
// sizes the column.
const inputOnly = (column: (typeof orderedColumns)[number]) =>
Boolean(onColumnRename) || (!column.cell && Boolean(column.editable));
const total = orderedColumns.reduce((sum, column) => {
const resized = widths[column.key];
if (resized != null) return sum + resized;
const declared = resolveColumnWidth(column.width, rootFontSize);
if (declared != null) return sum + declared;
return (
sum +
(inputOnly(column)
? Math.max(minColumnWidth, INPUT_COLUMN_WIDTH)
: minColumnWidth)
);
}, selectable ? CHECKBOX_PX : 0);
return Math.round(total);
}, [
minColumnWidth,
onColumnRename,
orderedColumns,
rootFontSize,
selectable,
widths,
]);
// Infinite scroll: fire onEndReached once per near-bottom dwell, paused while
// loading; the guard resets when the load completes.
const endReachedRef = useRef(false);
useEffect(() => {
if (!loading) endReachedRef.current = false;
}, [loading]);
const handleScroll = useCallback(() => {
const el = scrollRef.current;
if (!el || !onEndReached || loading || endReachedRef.current) return;
if (el.scrollHeight - el.scrollTop - el.clientHeight < rowHeight * 4) {
endReachedRef.current = true;
onEndReached();
}
}, [onEndReached, loading, rowHeight]);
const [activeColumn, setActiveColumn] = useState<string | null>(null);
// Small delay on leave so the pointer can cross the gap from the header cell
// to the portal handle without the column deactivating.
const deactivateTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const activateColumn = useCallback((key: string) => {
if (deactivateTimer.current) clearTimeout(deactivateTimer.current);
deactivateTimer.current = null;
setActiveColumn(key);
}, []);
const deactivateColumn = useCallback(() => {
if (deactivateTimer.current) clearTimeout(deactivateTimer.current);
deactivateTimer.current = setTimeout(() => setActiveColumn(null), 100);
}, []);
const rowRefs = useRef<Record<string, HTMLTableRowElement | null>>({});
const [activeRow, setActiveRow] = useState<{ id: string; index: number } | null>(
null,
);
const rowTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const activateRow = useCallback((id: string, index: number) => {
if (rowTimer.current) clearTimeout(rowTimer.current);
rowTimer.current = null;
setActiveRow({ id, index });
}, []);
const deactivateRow = useCallback(() => {
if (rowTimer.current) clearTimeout(rowTimer.current);
rowTimer.current = setTimeout(() => setActiveRow(null), 100);
}, []);
const activeRowEl = activeRow ? rowRefs.current[activeRow.id] : null;
// Real columns + checkbox; the trailing spacer adds one more in colSpans.
const leadColumns = columns.length + (selectable ? 1 : 0);
return (
<div
className={cn(
"w-full overflow-hidden border border-border bg-background text-sm",
className,
)}
>
<div
ref={scrollRef}
onScroll={handleScroll}
className="overflow-auto"
style={{ height }}
>
<table
className={cn("border-collapse", sized ? "w-max" : undefined)}
style={{
tableLayout: "fixed",
minWidth: `max(100%, ${minTableWidth}px)`,
}}
>
<colgroup>
{selectable ? <col style={{ width: CHECKBOX_WIDTH }} /> : null}
{orderedColumns.map((column) => {
const override = widths[column.key];
const width = override ? `${override}px` : column.width;
return (
<col key={column.key} style={width ? { width } : undefined} />
);
})}
{/* Empty filler owns the leftover space — no gap, content unpinned. */}
<col />
</colgroup>
<TableHeader
columns={orderedColumns}
rowHeight={rowHeight}
reduce={!!reduce}
thRefs={thRefs}
selectable={selectable}
allSelected={allSelected}
someSelected={someSelected}
onToggleAll={toggleAll}
sort={sort}
onToggleSort={toggleSort}
resizable={resizable}
onResizeStart={startResize}
onResizeMove={moveResize}
onResizeEnd={endResize}
reorderable={reorderable}
dragKey={dragKey}
dropIndex={dropIndex}
onReorderStart={startReorder}
onReorderMove={moveReorder}
onReorderEnd={endReorder}
onInsertColumn={onInsertColumn}
onDeleteColumn={onDeleteColumn}
onColumnRename={onColumnRename}
activeColumn={hasColumnMenu ? activeColumn : null}
onColumnActivate={hasColumnMenu ? activateColumn : undefined}
onColumnDeactivate={hasColumnMenu ? deactivateColumn : undefined}
/>
<tbody>
{sortedRows.length === 0 ? (
loading ? (
<SkeletonRows
count={Math.max(1, Math.ceil(height / rowHeight))}
columns={orderedColumns}
selectable={selectable}
rowHeight={rowHeight}
/>
) : (
<tr>
<td
colSpan={leadColumns + 1}
className="p-10 text-center text-muted-foreground"
>
{emptyState}
</td>
</tr>
)
) : (
<>
{paddingTop > 0 ? (
<tr aria-hidden style={{ height: paddingTop }}>
<td colSpan={leadColumns + 1} />
</tr>
) : null}
{virtualItems.map((vItem) => {
const entry = sortedRows[vItem.index];
const isSelected = selected.has(entry.id);
return (
<tr
key={entry.id}
ref={(el) => {
rowRefs.current[entry.id] = el;
}}
data-selected={isSelected}
style={{ height: rowHeight }}
onPointerEnter={
hasRowMenu
? () => activateRow(entry.id, vItem.index)
: undefined
}
onPointerLeave={hasRowMenu ? deactivateRow : undefined}
className={cn(
"border-border/60 border-b transition-colors",
"data-[selected=true]:bg-primary/5",
"hover:bg-muted/50",
)}
>
{selectable ? (
<td className="text-center">
<div className="flex items-center justify-center">
<Checkbox
checked={isSelected}
onCheckedChange={() => toggleRow(entry.id)}
aria-label={`Select row ${vItem.index + 1}`}
/>
</div>
</td>
) : null}
{orderedColumns.map((column) => (
<td
key={column.key}
className={cn(
"truncate px-4 text-foreground",
alignText(column.align),
)}
>
{!column.cell && column.editable ? (
<EditableCell
value={String(readCell(entry.row, column) ?? "")}
label={`${column.key} for row ${vItem.index + 1}`}
onChange={(next) =>
onCellEdit?.(entry.id, column.key, next)
}
/>
) : (
readCell(entry.row, column)
)}
</td>
))}
<td aria-hidden />
</tr>
);
})}
{paddingBottom > 0 ? (
<tr aria-hidden style={{ height: paddingBottom }}>
<td colSpan={leadColumns + 1} />
</tr>
) : null}
{loading ? (
<SkeletonRows
count={skeletonRows}
columns={orderedColumns}
selectable={selectable}
rowHeight={rowHeight}
/>
) : null}
</>
)}
</tbody>
</table>
</div>
{hasRowMenu && activeRow ? (
<RowHandle
rowEl={activeRowEl}
id={activeRow.id}
index={activeRow.index}
onInsertRow={onInsertRow}
onDeleteRow={onDeleteRow}
onEnter={() => activateRow(activeRow.id, activeRow.index)}
onLeave={deactivateRow}
/>
) : null}
</div>
);
}
"use client";
export function EditableCell({
value,
label,
onChange,
}: {
value: string;
label: string;
onChange: (next: string) => void;
}) {
return (
<input
value={value}
aria-label={label}
size={1}
onChange={(e) => onChange(e.target.value)}
placeholder="Empty"
className="-mx-2 w-full min-w-0 appearance-none rounded-md border-0 bg-transparent px-2 py-1 text-foreground outline-none transition-colors placeholder:text-muted-foreground/40 focus:bg-muted focus:ring-1 focus:ring-ring"
/>
);
}
"use client";
import { ArrowDownToLine, ArrowUpToLine, MoreVertical, Trash2 } from "lucide-react";
import { useEffect } from "react";
import { createPortal } from "react-dom";
import { TableMenu } from "./table-menu";
/** The row handle, portaled so it can sit on the row's left border without the
* scroll container clipping it. Straddles the border to bridge hover. */
export function RowHandle({
rowEl,
id,
index,
onInsertRow,
onDeleteRow,
onEnter,
onLeave,
}: {
rowEl: HTMLTableRowElement | null;
id: string;
index: number;
onInsertRow?: (index: number, position: "before" | "after") => void;
onDeleteRow?: (rowId: string, index: number) => void;
onEnter: () => void;
onLeave: () => void;
}) {
useEffect(() => {
window.addEventListener("scroll", onLeave, true);
return () => window.removeEventListener("scroll", onLeave, true);
}, [onLeave]);
if (!rowEl || typeof document === "undefined") return null;
const rect = rowEl.getBoundingClientRect();
return createPortal(
<div
style={{
position: "fixed",
top: rect.top + rect.height / 2,
left: rect.left,
transform: "translate(-50%, -50%)",
zIndex: 40,
}}
onPointerEnter={onEnter}
onPointerLeave={onLeave}
>
<TableMenu
ariaLabel={`Row ${index + 1} options`}
triggerClassName="flex h-6 w-2 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-sm transition-colors hover:bg-primary/90"
trigger={<MoreVertical className="h-3 w-3" />}
items={[
...(onInsertRow
? [
{
label: "Insert before",
icon: <ArrowUpToLine />,
onSelect: () => onInsertRow(index, "before"),
},
{
label: "Insert after",
icon: <ArrowDownToLine />,
onSelect: () => onInsertRow(index, "after"),
},
]
: []),
...(onDeleteRow
? [
{
label: "Delete row",
icon: <Trash2 />,
destructive: true,
onSelect: () => onDeleteRow(id, index),
},
]
: []),
]}
/>
</div>,
document.body,
);
}
"use client";
import { cn } from "@/lib/utils";
import type { TableColumn } from "./types";
import { alignText } from "./utils";
export function SkeletonRows<T>({
count,
columns,
selectable,
rowHeight,
}: {
count: number;
columns: TableColumn<T>[];
selectable: boolean;
rowHeight: number;
}) {
return (
<>
{Array.from({ length: count }, (_, r) => (
// biome-ignore lint/suspicious/noArrayIndexKey: static placeholder rows
<tr key={r} style={{ height: rowHeight }} className="border-border/60 border-b">
{selectable ? <td /> : null}
{columns.map((column) => (
<td key={column.key} className={cn("px-4", alignText(column.align))}>
<div
className={cn(
"h-3 animate-pulse rounded-full bg-muted",
column.align === "right" ? "ml-auto w-10" : "w-2/3",
)}
/>
</td>
))}
<td aria-hidden />
</tr>
))}
</>
);
}
"use client";
import {
ArrowLeftToLine,
ArrowRightToLine,
ChevronUp,
GripVertical,
MoreHorizontal,
Trash2,
} from "lucide-react";
import { motion } from "motion/react";
import { type PointerEvent as ReactPointerEvent, useEffect } from "react";
import { createPortal } from "react-dom";
import { Checkbox } from "@/components/motion/checkbox";
import { EASE_OUT, SPRING_PRESS } from "@/lib/ease";
import { TOUCH_GESTURE_CLASS } from "@/lib/touch";
import { cn } from "@/lib/utils";
import { TableMenu } from "./table-menu";
import type {
HeaderCellRefs,
InsertPosition,
SortState,
TableColumn,
} from "./types";
import { alignFlex, alignText, COLUMN_ACTIVE_SHADOW } from "./utils";
export interface TableHeaderProps<T> {
columns: TableColumn<T>[];
rowHeight: number;
reduce: boolean;
thRefs: HeaderCellRefs;
selectable: boolean;
allSelected: boolean;
someSelected: boolean;
onToggleAll: () => void;
sort: SortState | null;
onToggleSort: (key: string) => void;
resizable: boolean;
onResizeStart: (key: string, e: ReactPointerEvent) => void;
onResizeMove: (e: ReactPointerEvent) => void;
onResizeEnd: (e: ReactPointerEvent) => void;
reorderable: boolean;
dragKey: string | null;
dropIndex: number | null;
onReorderStart: (key: string, e: ReactPointerEvent) => void;
onReorderMove: (e: ReactPointerEvent) => void;
onReorderEnd: (e: ReactPointerEvent) => void;
onInsertColumn?: (index: number, position: InsertPosition) => void;
onDeleteColumn?: (columnKey: string, index: number) => void;
onColumnRename?: (columnKey: string, value: string) => void;
activeColumn: string | null;
onColumnActivate?: (key: string) => void;
onColumnDeactivate?: () => void;
}
/** Column insert / delete menu items shared by the header cell and the portal handle. */
function columnMenuItems<T>(
column: TableColumn<T>,
index: number,
onInsertColumn?: (index: number, position: InsertPosition) => void,
onDeleteColumn?: (columnKey: string, index: number) => void,
) {
return [
...(onInsertColumn
? [
{
label: "Insert before",
icon: <ArrowLeftToLine />,
onSelect: () => onInsertColumn(index, "before"),
},
{
label: "Insert after",
icon: <ArrowRightToLine />,
onSelect: () => onInsertColumn(index, "after"),
},
]
: []),
...(onDeleteColumn
? [
{
label: "Delete column",
icon: <Trash2 />,
destructive: true,
onSelect: () => onDeleteColumn(column.key, index),
},
]
: []),
];
}
/** The ellipse handle, portaled so it can sit on the column's top border without
* the scroll container clipping it. Straddles the border to bridge hover. */
function ColumnHandle<T>({
column,
index,
thRefs,
onInsertColumn,
onDeleteColumn,
onEnter,
onLeave,
}: {
column: TableColumn<T>;
index: number;
thRefs: HeaderCellRefs;
onInsertColumn?: (index: number, position: InsertPosition) => void;
onDeleteColumn?: (columnKey: string, index: number) => void;
onEnter: () => void;
onLeave: () => void;
}) {
useEffect(() => {
window.addEventListener("scroll", onLeave, true);
return () => window.removeEventListener("scroll", onLeave, true);
}, [onLeave]);
const el = thRefs.current[column.key];
if (!el || typeof document === "undefined") return null;
const rect = el.getBoundingClientRect();
return createPortal(
<div
style={{
position: "fixed",
top: rect.top,
left: rect.left + rect.width / 2,
transform: "translate(-50%, -50%)",
zIndex: 40,
}}
onPointerEnter={onEnter}
onPointerLeave={onLeave}
>
<TableMenu
ariaLabel={`${column.key} column options`}
triggerClassName="flex h-2 w-6 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-sm transition-colors hover:bg-primary/90"
trigger={<MoreHorizontal className="h-3 w-3" />}
items={columnMenuItems(column, index, onInsertColumn, onDeleteColumn)}
/>
</div>,
document.body,
);
}
export function TableHeader<T>({
columns,
rowHeight,
reduce,
thRefs,
selectable,
allSelected,
someSelected,
onToggleAll,
sort,
onToggleSort,
resizable,
onResizeStart,
onResizeMove,
onResizeEnd,
reorderable,
dragKey,
dropIndex,
onReorderStart,
onReorderMove,
onReorderEnd,
onInsertColumn,
onDeleteColumn,
onColumnRename,
activeColumn,
onColumnActivate,
onColumnDeactivate,
}: TableHeaderProps<T>) {
const hasColumnMenu = !!(onInsertColumn || onDeleteColumn);
const activeIndex = columns.findIndex((c) => c.key === activeColumn);
return (
<>
{hasColumnMenu && activeColumn && activeIndex >= 0 ? (
<ColumnHandle
column={columns[activeIndex]}
index={activeIndex}
thRefs={thRefs}
onInsertColumn={onInsertColumn}
onDeleteColumn={onDeleteColumn}
onEnter={() => onColumnActivate?.(activeColumn)}
onLeave={() => onColumnDeactivate?.()}
/>
) : null}
<thead>
<tr style={{ height: rowHeight }}>
{selectable ? (
<th className="sticky top-0 z-10 border-border border-b bg-muted">
<div className="flex items-center justify-center">
<Checkbox
checked={allSelected}
indeterminate={!allSelected && someSelected}
onCheckedChange={onToggleAll}
aria-label="Select all rows"
className="size-6"
/>
</div>
</th>
) : null}
{columns.map((column, index) => {
const active = sort?.key === column.key;
const isDragging = dragKey === column.key;
const isActive = activeColumn === column.key;
return (
<th
key={column.key}
ref={(el) => {
thRefs.current[column.key] = el;
}}
onPointerEnter={() => onColumnActivate?.(column.key)}
onPointerLeave={() => onColumnDeactivate?.()}
style={isActive ? { boxShadow: COLUMN_ACTIVE_SHADOW } : undefined}
aria-sort={
active
? sort?.direction === "asc"
? "ascending"
: "descending"
: undefined
}
data-drop={dragKey ? dropIndex === index : undefined}
data-dropend={
dragKey
? dropIndex === columns.length && index === columns.length - 1
: undefined
}
className={cn(
"group sticky top-0 z-10 border-border border-b bg-muted p-0 font-medium text-muted-foreground",
"data-[drop=true]:before:absolute data-[drop=true]:before:inset-y-0 data-[drop=true]:before:left-0 data-[drop=true]:before:w-0.5 data-[drop=true]:before:bg-primary",
"data-[dropend=true]:after:absolute data-[dropend=true]:after:inset-y-0 data-[dropend=true]:after:right-0 data-[dropend=true]:after:w-0.5 data-[dropend=true]:after:bg-primary",
)}
>
<motion.div
className={cn(
"flex h-full items-center",
alignFlex(column.align),
)}
style={{ height: rowHeight }}
animate={
reduce
? { opacity: isDragging ? 0.5 : 1 }
: {
scale: isDragging ? 1.04 : 1,
opacity: isDragging ? 0.5 : 1,
}
}
transition={SPRING_PRESS}
>
{reorderable ? (
<button
type="button"
aria-label={`Reorder ${column.key} column`}
onPointerDown={(e) => onReorderStart(column.key, e)}
onPointerMove={onReorderMove}
onPointerUp={onReorderEnd}
className={cn(
"flex h-full w-6 cursor-grab touch-none items-center justify-center text-muted-foreground/60 transition-colors hover:text-foreground active:cursor-grabbing",
// The grip owns the whole press, so iOS must not open its
// callout out of the same one and cancel the drag.
TOUCH_GESTURE_CLASS,
)}
>
<GripVertical className="h-3.5 w-3.5" />
</button>
) : null}
{column.sortable ? (
<button
type="button"
onClick={() => onToggleSort(column.key)}
className={cn(
"flex h-full min-w-0 flex-1 select-none items-center gap-1 px-4 transition-colors hover:text-foreground",
alignFlex(column.align),
active && "text-foreground",
)}
>
<span className="truncate">{column.header}</span>
<motion.span
aria-hidden
className="inline-flex shrink-0"
animate={{
rotate: active && sort?.direction === "desc" ? 180 : 0,
opacity: active ? 1 : 0.35,
}}
transition={
reduce
? { duration: 0 }
: { duration: 0.18, ease: EASE_OUT }
}
>
<ChevronUp className="h-3.5 w-3.5" />
</motion.span>
</button>
) : onColumnRename ? (
<input
value={
typeof column.header === "string" ? column.header : ""
}
aria-label={`Rename ${column.key} column`}
size={1}
onChange={(e) =>
onColumnRename(column.key, e.target.value)
}
className={cn(
"min-w-0 flex-1 truncate appearance-none rounded-md border-0 bg-transparent px-4 font-medium text-muted-foreground outline-none transition-colors focus:bg-muted focus:text-foreground",
alignText(column.align),
)}
/>
) : (
<span
className={cn(
"min-w-0 flex-1 truncate px-4",
alignText(column.align),
)}
>
{column.header}
</span>
)}
</motion.div>
{resizable ? (
<button
type="button"
aria-label={`Resize ${column.key} column`}
tabIndex={-1}
onPointerDown={(e) => onResizeStart(column.key, e)}
onPointerMove={onResizeMove}
onPointerUp={onResizeEnd}
className={cn(
"absolute top-0 right-0 h-full w-1.5 cursor-col-resize touch-none bg-transparent transition-colors hover:bg-primary/40",
// Same for the resize drag: the handle drives it end to end.
TOUCH_GESTURE_CLASS,
)}
/>
) : null}
</th>
);
})}
<th
aria-hidden
className="sticky top-0 z-10 border-border border-b bg-muted"
/>
</tr>
</thead>
</>
);
}
import type { ReactNode } from "react";
export type SortDirection = "asc" | "desc";
export type SortState = {
key: string;
direction: SortDirection;
};
export type TableColumn<T> = {
/** Stable key; also the default object property read for the cell + sort value. */
key: string;
/** Header content. */
header: ReactNode;
/** Allow clicking the header to sort by this column. */
sortable?: boolean;
/** Cell text alignment. */
align?: "left" | "center" | "right";
/** Column width as a CSS length, e.g. "160px" or "20%". Omit to share remaining space equally. */
width?: string;
/** Custom cell renderer. Falls back to `row[key]`. */
cell?: (row: T) => ReactNode;
/** Render an inline text input for this column's cells (ignored when `cell` is set). */
editable?: boolean;
/** Value used for sorting. Falls back to `row[key]`. */
sortValue?: (row: T) => string | number;
};
export type InsertPosition = "before" | "after";
export interface TableProps<T> {
data: T[];
columns: TableColumn<T>[];
/** Stable id per row, required for correct selection across sorts. Defaults to row index. */
getRowId?: (row: T, index: number) => string;
/** Render a leading checkbox column with select-all in the header. */
selectable?: boolean;
selectedRowIds?: string[];
defaultSelectedRowIds?: string[];
onSelectionChange?: (ids: string[]) => void;
sort?: SortState | null;
defaultSort?: SortState | null;
onSortChange?: (sort: SortState | null) => void;
/** Allow dragging the right edge of a header to resize that column. */
resizable?: boolean;
/** Minimum column width in px when resizing. */
minColumnWidth?: number;
onColumnResize?: (key: string, width: number) => void;
/** Allow dragging a header grip to reorder columns. */
reorderable?: boolean;
onColumnOrderChange?: (keys: string[]) => void;
/** Called when an `editable` cell changes. */
onCellEdit?: (rowId: string, columnKey: string, value: string) => void;
/** When set, non-sortable headers become editable inputs for the column name. */
onColumnRename?: (columnKey: string, value: string) => void;
/** Enables the row menu (Insert before / after). Receives the target index. */
onInsertRow?: (index: number, position: InsertPosition) => void;
/** Enables Delete in the row menu. */
onDeleteRow?: (rowId: string, index: number) => void;
/** Enables the column menu (Insert before / after). Receives the target column index. */
onInsertColumn?: (index: number, position: InsertPosition) => void;
/** Enables Delete in the column menu. */
onDeleteColumn?: (columnKey: string, index: number) => void;
/** Fixed row height in px — required for virtualization. */
rowHeight?: number;
/** Scroll viewport height in px. */
height?: number;
/** Rows rendered above/below the viewport. */
overscan?: number;
/** Fires when the viewport scrolls near the bottom — load the next page. */
onEndReached?: () => void;
/** Currently fetching — shows skeleton rows and pauses `onEndReached`. */
loading?: boolean;
/** How many skeleton rows to show while loading more (default 3). */
skeletonRows?: number;
emptyState?: ReactNode;
className?: string;
}
/** A data row paired with its stable id. */
export type TableRow<T> = { row: T; id: string };
/** Ref map from column key to its header cell, shared across the resize/reorder hooks. */
export type HeaderCellRefs = {
current: Record<string, HTMLTableCellElement | null>;
};
import {
type PointerEvent as ReactPointerEvent,
useCallback,
useMemo,
useState,
} from "react";
import { capturePointer, releasePointer } from "@/lib/touch";
import type { HeaderCellRefs, TableColumn } from "./types";
export function useColumnReorder<T>({
columns,
thRefs,
onColumnOrderChange,
}: {
columns: TableColumn<T>[];
thRefs: HeaderCellRefs;
onColumnOrderChange?: (keys: string[]) => void;
}) {
const [order, setOrder] = useState<string[]>(() =>
columns.map((c) => c.key),
);
const [dragKey, setDragKey] = useState<string | null>(null);
const [dropIndex, setDropIndex] = useState<number | null>(null);
// Apply the current order, tolerating columns added/removed at runtime. New
// columns are placed at their position in `columns` (after their left
// neighbor), not appended — so an inserted column lands where it was added.
const orderedColumns = useMemo(() => {
const byKey = new Map(columns.map((c) => [c.key, c]));
const resultKeys = order.filter((k) => byKey.has(k));
const present = new Set(resultKeys);
columns.forEach((column, i) => {
if (present.has(column.key)) return;
let at = resultKeys.length;
if (i === 0) {
at = 0;
} else {
const idx = resultKeys.indexOf(columns[i - 1].key);
at = idx === -1 ? i : idx + 1;
}
resultKeys.splice(at, 0, column.key);
present.add(column.key);
});
return resultKeys
.map((k) => byKey.get(k))
.filter((c): c is TableColumn<T> => c !== undefined);
}, [order, columns]);
const dropIndexFor = useCallback(
(clientX: number) => {
for (let i = 0; i < orderedColumns.length; i++) {
const rect =
thRefs.current[orderedColumns[i].key]?.getBoundingClientRect();
if (rect && clientX < rect.left + rect.width / 2) return i;
}
return orderedColumns.length;
},
[orderedColumns, thRefs],
);
const startReorder = useCallback((key: string, e: ReactPointerEvent) => {
e.preventDefault();
e.stopPropagation();
setDragKey(key);
capturePointer(e.currentTarget, e.pointerId);
}, []);
const moveReorder = useCallback(
(e: ReactPointerEvent) => {
if (!dragKey) return;
setDropIndex(dropIndexFor(e.clientX));
},
[dragKey, dropIndexFor],
);
const endReorder = useCallback(
(e: ReactPointerEvent) => {
releasePointer(e.currentTarget, e.pointerId);
if (dragKey && dropIndex !== null) {
const keys = orderedColumns.map((c) => c.key);
const from = keys.indexOf(dragKey);
if (from !== -1) {
const without = keys.filter((_, i) => i !== from);
let to = dropIndex;
if (from < to) to--;
without.splice(to, 0, dragKey);
setOrder(without);
onColumnOrderChange?.(without);
}
}
setDragKey(null);
setDropIndex(null);
},
[dragKey, dropIndex, orderedColumns, onColumnOrderChange],
);
return {
orderedColumns,
dragKey,
dropIndex,
startReorder,
moveReorder,
endReorder,
};
}
import {
type PointerEvent as ReactPointerEvent,
useCallback,
useRef,
useState,
} from "react";
import { capturePointer, releasePointer } from "@/lib/touch";
import type { HeaderCellRefs, TableColumn } from "./types";
export function useColumnResize<T>({
orderedColumns,
thRefs,
minColumnWidth,
onColumnResize,
}: {
orderedColumns: TableColumn<T>[];
thRefs: HeaderCellRefs;
minColumnWidth: number;
onColumnResize?: (key: string, width: number) => void;
}) {
const resizeRef = useRef<{
key: string;
startX: number;
startWidth: number;
} | null>(null);
const [widths, setWidths] = useState<Record<string, number>>({});
const startResize = useCallback(
(key: string, e: ReactPointerEvent) => {
e.preventDefault();
e.stopPropagation();
// Freeze every column to its current pixel width so resizing one only
// moves the trailing spacer, never the other columns.
const snapshot = { ...widths };
for (const column of orderedColumns) {
if (snapshot[column.key] == null) {
const measured = thRefs.current[column.key]?.getBoundingClientRect()
.width;
snapshot[column.key] = measured
? Math.round(measured)
: minColumnWidth;
}
}
resizeRef.current = {
key,
startX: e.clientX,
startWidth: snapshot[key],
};
setWidths(snapshot);
capturePointer(e.currentTarget, e.pointerId);
},
[minColumnWidth, orderedColumns, thRefs, widths],
);
const moveResize = useCallback(
(e: ReactPointerEvent) => {
const state = resizeRef.current;
if (!state) return;
const width = Math.max(
minColumnWidth,
state.startWidth + (e.clientX - state.startX),
);
setWidths((prev) => ({ ...prev, [state.key]: width }));
},
[minColumnWidth],
);
const endResize = useCallback(
(e: ReactPointerEvent) => {
const state = resizeRef.current;
resizeRef.current = null;
releasePointer(e.currentTarget, e.pointerId);
if (state) {
onColumnResize?.(state.key, widths[state.key] ?? state.startWidth);
}
},
[onColumnResize, widths],
);
return { widths, startResize, moveResize, endResize };
}
import { useCallback, useMemo, useState } from "react";
import type { SortState, TableColumn, TableRow } from "./types";
import { readSortValue } from "./utils";
export function useColumnSort<T>({
rows,
columns,
sort: sortProp,
defaultSort = null,
onSortChange,
}: {
rows: TableRow<T>[];
columns: TableColumn<T>[];
sort?: SortState | null;
defaultSort?: SortState | null;
onSortChange?: (sort: SortState | null) => void;
}) {
const [internalSort, setInternalSort] = useState<SortState | null>(
defaultSort,
);
const sort = sortProp !== undefined ? sortProp : internalSort;
const commit = useCallback(
(next: SortState | null) => {
if (sortProp === undefined) setInternalSort(next);
onSortChange?.(next);
},
[sortProp, onSortChange],
);
const toggleSort = useCallback(
(key: string) => {
if (!sort || sort.key !== key) {
commit({ key, direction: "asc" });
} else if (sort.direction === "asc") {
commit({ key, direction: "desc" });
} else {
commit(null);
}
},
[sort, commit],
);
const sortedRows = useMemo(() => {
if (!sort) return rows;
const column = columns.find((c) => c.key === sort.key);
if (!column) return rows;
const copy = [...rows];
copy.sort((a, b) => {
const av = readSortValue(a.row, column);
const bv = readSortValue(b.row, column);
let cmp: number;
if (typeof av === "number" && typeof bv === "number") {
cmp = av - bv;
} else {
cmp = String(av).localeCompare(String(bv));
}
return sort.direction === "asc" ? cmp : -cmp;
});
return copy;
}, [rows, sort, columns]);
return { sort, sortedRows, toggleSort };
}
import { useCallback, useMemo, useState } from "react";
import type { TableRow } from "./types";
export function useRowSelection<T>({
sortedRows,
selectedRowIds,
defaultSelectedRowIds,
onSelectionChange,
}: {
sortedRows: TableRow<T>[];
selectedRowIds?: string[];
defaultSelectedRowIds?: string[];
onSelectionChange?: (ids: string[]) => void;
}) {
const [internalSelected, setInternalSelected] = useState<Set<string>>(
() => new Set(defaultSelectedRowIds),
);
const selected = useMemo(
() =>
selectedRowIds !== undefined
? new Set(selectedRowIds)
: internalSelected,
[selectedRowIds, internalSelected],
);
const commit = useCallback(
(next: Set<string>) => {
if (selectedRowIds === undefined) setInternalSelected(next);
onSelectionChange?.([...next]);
},
[selectedRowIds, onSelectionChange],
);
const allSelected =
sortedRows.length > 0 && sortedRows.every((r) => selected.has(r.id));
const someSelected = sortedRows.some((r) => selected.has(r.id));
const toggleAll = useCallback(() => {
const next = new Set(selected);
if (allSelected) {
for (const r of sortedRows) next.delete(r.id);
} else {
for (const r of sortedRows) next.add(r.id);
}
commit(next);
}, [allSelected, sortedRows, selected, commit]);
const toggleRow = useCallback(
(id: string) => {
const next = new Set(selected);
if (next.has(id)) next.delete(id);
else next.add(id);
commit(next);
},
[selected, commit],
);
return { selected, allSelected, someSelected, toggleAll, toggleRow };
}
import type { ReactNode } from "react";
import type { TableColumn } from "./types";
export const CHECKBOX_PX = 48;
export const CHECKBOX_WIDTH = `${CHECKBOX_PX}px`;
/** Highlights the top edge of the active column's header cell. */
export const COLUMN_ACTIVE_SHADOW = "inset 0 1px 0 var(--color-primary)";
export function alignFlex(align: TableColumn<unknown>["align"]) {
if (align === "right") return "justify-end";
if (align === "center") return "justify-center";
return "justify-start";
}
export function alignText(align: TableColumn<unknown>["align"]) {
if (align === "right") return "text-right";
if (align === "center") return "text-center";
return "text-left";
}
export function readCell<T>(row: T, column: TableColumn<T>): ReactNode {
if (column.cell) return column.cell(row);
return (row as Record<string, ReactNode>)[column.key];
}
export function readSortValue<T>(
row: T,
column: TableColumn<T>,
): string | number {
if (column.sortValue) return column.sortValue(row);
return (row as Record<string, string | number>)[column.key];
}
"use client";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useId } from "react";
import { EASE_OUT, SPRING_PRESS } from "@/lib/ease";
import { cn } from "@/lib/utils";
const CHECK_PATH = "M5 13l4 4L19 7";
const INDETERMINATE_PATH = "M6 12h12";
export interface CheckboxProps {
checked: boolean;
onCheckedChange: (checked: boolean) => void;
disabled?: boolean;
indeterminate?: boolean;
label?: string;
className?: string;
id?: string;
"aria-label"?: string;
/** Associates an external message (e.g. a form error) with the control. */
"aria-describedby"?: string;
}
export function Checkbox({
checked,
onCheckedChange,
disabled,
indeterminate,
label,
className,
id: idProp,
"aria-label": ariaLabel,
"aria-describedby": ariaDescribedBy,
}: CheckboxProps) {
const autoId = useId();
const id = idProp ?? autoId;
const reduce = useReducedMotion();
const showMark = checked || indeterminate;
const path = indeterminate ? INDETERMINATE_PATH : CHECK_PATH;
return (
<label
htmlFor={id}
className={cn(
"inline-flex items-center gap-3",
disabled ? "cursor-not-allowed" : "cursor-pointer",
className,
)}
>
<motion.button
id={id}
type="button"
role="checkbox"
aria-checked={indeterminate ? "mixed" : checked}
aria-label={ariaLabel}
aria-describedby={ariaDescribedBy}
disabled={disabled}
onClick={() => !disabled && onCheckedChange(!checked)}
whileTap={reduce || disabled ? undefined : { scale: 0.92 }}
transition={SPRING_PRESS}
data-state={
checked ? "checked" : indeterminate ? "indeterminate" : "unchecked"
}
className={cn(
"inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-md border-2 outline-none transition-colors duration-200",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"disabled:cursor-not-allowed disabled:opacity-60",
showMark
? "border-primary bg-primary text-primary-foreground"
: "border-muted-foreground/50 bg-background hover:border-muted-foreground",
)}
>
<AnimatePresence initial={false}>
{showMark ? (
<motion.svg
key={indeterminate ? "indeterminate" : "checked"}
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={3}
strokeLinecap="round"
strokeLinejoin="round"
initial={reduce ? { opacity: 1 } : { opacity: 0, scale: 0.5 }}
animate={reduce ? { opacity: 1 } : { opacity: 1, scale: 1 }}
exit={
reduce
? { opacity: 0 }
: { opacity: 0, scale: 0.5, filter: "blur(4px)" }
}
transition={
reduce ? { duration: 0 } : { duration: 0.16, ease: EASE_OUT }
}
aria-hidden
>
<title>{indeterminate ? "Partially selected" : "Selected"}</title>
<motion.path
d={path}
initial={reduce ? { pathLength: 1 } : { pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={
reduce
? { duration: 0 }
: {
duration: indeterminate ? 0.2 : 0.3,
ease: EASE_OUT,
delay: 0.04,
}
}
/>
</motion.svg>
) : null}
</AnimatePresence>
</motion.button>
{label ? (
<span className={cn("select-none text-sm text-foreground", disabled && "opacity-60")}>
{label}
</span>
) : null}
</label>
);
}
"use client";
import { motion, useReducedMotion } from "motion/react";
import { type ReactNode, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { SPRING_PANEL } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type TableMenuItem = {
label: string;
icon?: ReactNode;
onSelect: () => void;
destructive?: boolean;
};
const MENU_WIDTH = 188;
export function TableMenu({
items,
ariaLabel,
trigger,
triggerClassName,
}: {
items: TableMenuItem[];
ariaLabel: string;
trigger: ReactNode;
triggerClassName?: string;
}) {
const reduce = useReducedMotion();
const triggerRef = useRef<HTMLButtonElement>(null);
const [coords, setCoords] = useState<{ top: number; left: number } | null>(
null,
);
const open = coords !== null;
useEffect(() => {
if (!open) return;
const close = () => setCoords(null);
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") close();
};
// Close on any scroll (the trigger moves) or resize; fixed coords go stale.
window.addEventListener("scroll", close, true);
window.addEventListener("resize", close);
window.addEventListener("keydown", onKey);
return () => {
window.removeEventListener("scroll", close, true);
window.removeEventListener("resize", close);
window.removeEventListener("keydown", onKey);
};
}, [open]);
const toggle = () => {
if (open) {
setCoords(null);
return;
}
const el = triggerRef.current;
if (!el) return;
const r = el.getBoundingClientRect();
setCoords({
top: r.bottom + 4,
left: Math.max(8, r.right - MENU_WIDTH),
});
};
return (
<>
<button
ref={triggerRef}
type="button"
aria-label={ariaLabel}
aria-haspopup="menu"
aria-expanded={open}
onClick={(e) => {
e.stopPropagation();
toggle();
}}
className={triggerClassName}
>
{trigger}
</button>
{open && typeof document !== "undefined"
? createPortal(
<>
<div
className="fixed inset-0 z-40"
onPointerDown={() => setCoords(null)}
/>
<motion.div
role="menu"
className="fixed z-50 overflow-hidden rounded-xl border border-border bg-background p-1 shadow-xl"
style={{ top: coords.top, left: coords.left, width: MENU_WIDTH }}
initial={
reduce ? { opacity: 0 } : { opacity: 0, scale: 0.96, y: -4 }
}
animate={
reduce ? { opacity: 1 } : { opacity: 1, scale: 1, y: 0 }
}
transition={reduce ? { duration: 0 } : SPRING_PANEL}
>
{items.map((item) => (
<button
key={item.label}
type="button"
role="menuitem"
onClick={() => {
setCoords(null);
item.onSelect();
}}
className={cn(
"flex w-full items-center gap-2.5 rounded-lg px-2.5 py-1.5 text-left text-sm transition-colors [&_svg]:h-4 [&_svg]:w-4",
item.destructive
? "text-rose-500 hover:bg-rose-500/10"
: "text-foreground hover:bg-muted",
)}
>
{item.icon}
{item.label}
</button>
))}
</motion.div>
</>,
document.body,
)
: null}
</>
);
}
API Reference
dataT[]—columnsTableColumn<T>[]—getRowId?((row: T, index: number) => string)Stable id per row, required for correct selection across sorts. Defaults to row index.
—selectable?booleanRender a leading checkbox column with select-all in the header.
falseselectedRowIds?string[]—defaultSelectedRowIds?string[]—onSelectionChange?((ids: string[]) => void)—sort?SortState | null—defaultSort?SortState | nullnullonSortChange?((sort: SortState | null) => void)—resizable?booleanAllow dragging the right edge of a header to resize that column.
falseminColumnWidth?numberMinimum column width in px when resizing.
64onColumnResize?((key: string, width: number) => void)—reorderable?booleanAllow dragging a header grip to reorder columns.
falseonColumnOrderChange?((keys: string[]) => void)—onCellEdit?((rowId: string, columnKey: string, value: string) => void)Called when an `editable` cell changes.
—onColumnRename?((columnKey: string, value: string) => void)When set, non-sortable headers become editable inputs for the column name.
—onInsertRow?((index: number, position: InsertPosition) => void)Enables the row menu (Insert before / after). Receives the target index.
—onDeleteRow?((rowId: string, index: number) => void)Enables Delete in the row menu.
—onInsertColumn?((index: number, position: InsertPosition) => void)Enables the column menu (Insert before / after). Receives the target column index.
—onDeleteColumn?((columnKey: string, index: number) => void)Enables Delete in the column menu.
—rowHeight?numberFixed row height in px — required for virtualization.
48height?numberScroll viewport height in px.
440overscan?numberRows rendered above/below the viewport.
10onEndReached?(() => void)Fires when the viewport scrolls near the bottom — load the next page.
—loading?booleanCurrently fetching — shows skeleton rows and pauses `onEndReached`.
falseskeletonRows?numberHow many skeleton rows to show while loading more (default 3).
3emptyState?ReactNodeNo dataclassName?string—Updated