Table
Virtualized data table that stays smooth at 10k+ rows, with sortable headers, row selection, optional bulk-action headers, 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(10000), []);
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";
// asharca.github.io/ui/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,
selectionActions,
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,
clearSelection,
} = useRowSelection({
sortedRows,
selectedRowIds,
defaultSelectedRowIds,
onSelectionChange,
});
const selectedEntries = useMemo(
() =>
selectable && selectionActions
? sortedRows.filter((entry) => selected.has(entry.id))
: [],
[selectable, selectionActions, sortedRows, selected],
);
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}
selectedCount={selectedEntries.length}
selectionActions={
selectedEntries.length > 0
? selectionActions?.({
selectedRows: selectedEntries.map((entry) => entry.row),
selectedRowIds: selectedEntries.map((entry) => entry.id),
clearSelection,
})
: undefined
}
onClearSelection={clearSelection}
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-muted/60",
"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";
// asharca.github.io/ui/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,
selectionActions,
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,
clearSelection,
} = useRowSelection({
sortedRows,
selectedRowIds,
defaultSelectedRowIds,
onSelectionChange,
});
const selectedEntries = useMemo(
() =>
selectable && selectionActions
? sortedRows.filter((entry) => selected.has(entry.id))
: [],
[selectable, selectionActions, sortedRows, selected],
);
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}
selectedCount={selectedEntries.length}
selectionActions={
selectedEntries.length > 0
? selectionActions?.({
selectedRows: selectedEntries.map((entry) => entry.row),
selectedRowIds: selectedEntries.map((entry) => entry.id),
clearSelection,
})
: undefined
}
onClearSelection={clearSelection}
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-muted/60",
"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,
type ReactNode,
useEffect,
useLayoutEffect,
useRef,
} 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,
CHECKBOX_PX,
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;
selectedCount: number;
selectionActions?: ReactNode;
onClearSelection: () => 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,
selectedCount,
selectionActions,
onClearSelection,
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);
const showSelection = selectable && selectedCount > 0;
const selectionHadFocus = useRef(false);
const checkboxRef = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
if (!showSelection && selectionHadFocus.current) {
if (document.activeElement === document.body) {
checkboxRef.current?.querySelector("button")?.focus();
}
selectionHadFocus.current = false;
}
}, [showSelection]);
return (
<>
{!showSelection && hasColumnMenu && activeColumn && activeIndex >= 0 ? (
<ColumnHandle
column={columns[activeIndex]}
index={activeIndex}
thRefs={thRefs}
onInsertColumn={onInsertColumn}
onDeleteColumn={onDeleteColumn}
onEnter={() => onColumnActivate?.(activeColumn)}
onLeave={() => onColumnDeactivate?.()}
/>
) : null}
<thead className="sticky top-0 z-10">
<tr style={{ height: rowHeight }}>
{selectable ? (
<th className="sticky top-0 z-10 border-border border-b bg-muted">
<div
ref={checkboxRef}
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 = !showSelection && activeColumn === column.key;
return (
<th
key={column.key}
ref={(el) => {
thRefs.current[column.key] = el;
}}
onPointerEnter={
showSelection
? undefined
: () => 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",
)}
>
{showSelection ? (
<span className="sr-only">{column.header}</span>
) : null}
<motion.div
inert={showSelection}
className={cn(
"flex h-full items-center",
alignFlex(column.align),
)}
style={{
height: rowHeight,
visibility: showSelection ? "hidden" : undefined,
}}
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 && !showSelection ? (
<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-muted",
// 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>
{showSelection ? (
<tr
className="absolute inset-y-0 right-0 z-20"
style={{ left: CHECKBOX_PX }}
>
<td
colSpan={columns.length + 1}
className="block h-full bg-muted p-0"
>
<motion.div
onFocusCapture={() => {
selectionHadFocus.current = true;
}}
onBlurCapture={(event) => {
selectionHadFocus.current = event.currentTarget.contains(
event.relatedTarget,
);
}}
role="group"
aria-label="Selection actions"
initial={{ opacity: 0, y: reduce ? 0 : 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.16, ease: EASE_OUT }}
className="flex h-full items-center gap-3 overflow-x-auto border-border border-b px-4 font-normal"
>
<span
role="status"
className="shrink-0 font-medium tabular-nums"
>
{selectedCount} selected
</span>
<div className="flex items-center gap-2">
{selectionActions}
</div>
<button
type="button"
onClick={onClearSelection}
className="ml-auto shrink-0 rounded-md px-2 py-1 text-muted-foreground transition-colors hover:bg-background hover:text-foreground focus-visible:outline-2 focus-visible:outline-ring"
>
Clear selection
</button>
</motion.div>
</td>
</tr>
) : null}
</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;
/** Replaces column controls while loaded rows are selected; requires `selectable`.
* Rows/ids follow the current sort and exclude ids absent from `data`.
* `clearSelection` clears every selected id, including unloaded rows. */
selectionActions?: (selection: {
selectedRows: T[];
selectedRowIds: string[];
clearSelection: () => void;
}) => ReactNode;
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],
);
const clearSelection = useCallback(() => commit(new Set()), [commit]);
return {
selected,
allSelected,
someSelected,
toggleAll,
toggleRow,
clearSelection,
};
}
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}
</>
);
}
Bulk Actions
index.tsxSelect rows to replace column controls with a selected count and custom selectionActions. Activate or delete selected members, clear selection to restore the header, and keep column widths and sorting intact.
Select rows to turn the header into bulk actions.
Email |
|---|
"use client";
import { Check, RotateCcw, Trash2 } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/motion/button";
import { Table, type TableColumn } from "@/components/motion/table";
type Member = {
id: string;
name: string;
email: string;
status: string;
};
const MEMBERS: Member[] = [
{ id: "ava", name: "Ava Cole", email: "ava@example.com", status: "Invited" },
{ id: "leo", name: "Leo Frost", email: "leo@example.com", status: "Active" },
{ id: "mia", name: "Mia Vale", email: "mia@example.com", status: "Invited" },
{ id: "kai", name: "Kai Reyes", email: "kai@example.com", status: "Active" },
{ id: "zoe", name: "Zoe Sato", email: "zoe@example.com", status: "Invited" },
{ id: "eli", name: "Eli Marsh", email: "eli@example.com", status: "Active" },
];
const COLUMNS: TableColumn<Member>[] = [
{ key: "name", header: "Name", sortable: true, width: "180px" },
{ key: "email", header: "Email", width: "240px" },
{ key: "status", header: "Status", sortable: true, width: "120px" },
];
export function TableSelectionPreview() {
const [data, setData] = useState(MEMBERS);
const [selected, setSelected] = useState<string[]>([]);
return (<div className="flex w-full flex-col gap-3 p-4">
<div className="flex items-center justify-between gap-3">
<p className="text-muted-foreground text-xs">
Select rows to turn the header into bulk actions.
</p>
<Button variant="ghost" size="sm" onClick={() => {
setData(MEMBERS);
setSelected([]);
}}>
<RotateCcw aria-hidden className="size-3.5"/> Reset
</Button>
</div>
<Table data={data} columns={COLUMNS} getRowId={(row) => row.id} selectable resizable selectedRowIds={selected} onSelectionChange={setSelected} selectionActions={({ selectedRowIds, clearSelection }) => {
const ids = new Set(selectedRowIds);
return (<>
<Button variant="outline" size="sm" onClick={() => {
setData((rows) => rows.map((row) => ids.has(row.id) ? { ...row, status: "Active" } : row));
clearSelection();
}}>
<Check aria-hidden className="size-3.5"/> Activate
</Button>
<Button variant="ghost" size="sm" className="text-destructive hover:text-destructive" onClick={() => {
setData((rows) => rows.filter((row) => !ids.has(row.id)));
clearSelection();
}}>
<Trash2 aria-hidden className="size-3.5"/> Delete
</Button>
</>);
}} height={364} rowHeight={52} emptyState="No members left. Reset to try again." className="rounded-2xl"/>
</div>);
}
"use client";
// asharca.github.io/ui/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,
selectionActions,
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,
clearSelection,
} = useRowSelection({
sortedRows,
selectedRowIds,
defaultSelectedRowIds,
onSelectionChange,
});
const selectedEntries = useMemo(
() =>
selectable && selectionActions
? sortedRows.filter((entry) => selected.has(entry.id))
: [],
[selectable, selectionActions, sortedRows, selected],
);
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}
selectedCount={selectedEntries.length}
selectionActions={
selectedEntries.length > 0
? selectionActions?.({
selectedRows: selectedEntries.map((entry) => entry.row),
selectedRowIds: selectedEntries.map((entry) => entry.id),
clearSelection,
})
: undefined
}
onClearSelection={clearSelection}
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-muted/60",
"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;
"use client";
import { useEffect, useState } from "react";
/**
* Returns true only on devices that have a true hover (mouse / trackpad).
* Touch devices fire phantom `:hover` on tap that sticks until tap-elsewhere
* — gate hover-only effects (scale lifts, magnetic pulls) behind this.
*/
export function useHoverCapable() {
const [canHover, setCanHover] = useState(false);
useEffect(() => {
if (typeof window === "undefined" || !window.matchMedia) return;
const mq = window.matchMedia("(hover: hover) and (pointer: fine)");
const update = () => setCanHover(mq.matches);
update();
mq.addEventListener?.("change", update);
return () => mq.removeEventListener?.("change", update);
}, []);
return canHover;
}
Copy the source code
"use client";
// asharca.github.io/ui/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,
selectionActions,
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,
clearSelection,
} = useRowSelection({
sortedRows,
selectedRowIds,
defaultSelectedRowIds,
onSelectionChange,
});
const selectedEntries = useMemo(
() =>
selectable && selectionActions
? sortedRows.filter((entry) => selected.has(entry.id))
: [],
[selectable, selectionActions, sortedRows, selected],
);
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}
selectedCount={selectedEntries.length}
selectionActions={
selectedEntries.length > 0
? selectionActions?.({
selectedRows: selectedEntries.map((entry) => entry.row),
selectedRowIds: selectedEntries.map((entry) => entry.id),
clearSelection,
})
: undefined
}
onClearSelection={clearSelection}
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-muted/60",
"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,
type ReactNode,
useEffect,
useLayoutEffect,
useRef,
} 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,
CHECKBOX_PX,
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;
selectedCount: number;
selectionActions?: ReactNode;
onClearSelection: () => 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,
selectedCount,
selectionActions,
onClearSelection,
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);
const showSelection = selectable && selectedCount > 0;
const selectionHadFocus = useRef(false);
const checkboxRef = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
if (!showSelection && selectionHadFocus.current) {
if (document.activeElement === document.body) {
checkboxRef.current?.querySelector("button")?.focus();
}
selectionHadFocus.current = false;
}
}, [showSelection]);
return (
<>
{!showSelection && hasColumnMenu && activeColumn && activeIndex >= 0 ? (
<ColumnHandle
column={columns[activeIndex]}
index={activeIndex}
thRefs={thRefs}
onInsertColumn={onInsertColumn}
onDeleteColumn={onDeleteColumn}
onEnter={() => onColumnActivate?.(activeColumn)}
onLeave={() => onColumnDeactivate?.()}
/>
) : null}
<thead className="sticky top-0 z-10">
<tr style={{ height: rowHeight }}>
{selectable ? (
<th className="sticky top-0 z-10 border-border border-b bg-muted">
<div
ref={checkboxRef}
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 = !showSelection && activeColumn === column.key;
return (
<th
key={column.key}
ref={(el) => {
thRefs.current[column.key] = el;
}}
onPointerEnter={
showSelection
? undefined
: () => 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",
)}
>
{showSelection ? (
<span className="sr-only">{column.header}</span>
) : null}
<motion.div
inert={showSelection}
className={cn(
"flex h-full items-center",
alignFlex(column.align),
)}
style={{
height: rowHeight,
visibility: showSelection ? "hidden" : undefined,
}}
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 && !showSelection ? (
<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-muted",
// 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>
{showSelection ? (
<tr
className="absolute inset-y-0 right-0 z-20"
style={{ left: CHECKBOX_PX }}
>
<td
colSpan={columns.length + 1}
className="block h-full bg-muted p-0"
>
<motion.div
onFocusCapture={() => {
selectionHadFocus.current = true;
}}
onBlurCapture={(event) => {
selectionHadFocus.current = event.currentTarget.contains(
event.relatedTarget,
);
}}
role="group"
aria-label="Selection actions"
initial={{ opacity: 0, y: reduce ? 0 : 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.16, ease: EASE_OUT }}
className="flex h-full items-center gap-3 overflow-x-auto border-border border-b px-4 font-normal"
>
<span
role="status"
className="shrink-0 font-medium tabular-nums"
>
{selectedCount} selected
</span>
<div className="flex items-center gap-2">
{selectionActions}
</div>
<button
type="button"
onClick={onClearSelection}
className="ml-auto shrink-0 rounded-md px-2 py-1 text-muted-foreground transition-colors hover:bg-background hover:text-foreground focus-visible:outline-2 focus-visible:outline-ring"
>
Clear selection
</button>
</motion.div>
</td>
</tr>
) : null}
</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;
/** Replaces column controls while loaded rows are selected; requires `selectable`.
* Rows/ids follow the current sort and exclude ids absent from `data`.
* `clearSelection` clears every selected id, including unloaded rows. */
selectionActions?: (selection: {
selectedRows: T[];
selectedRowIds: string[];
clearSelection: () => void;
}) => ReactNode;
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],
);
const clearSelection = useCallback(() => commit(new Set()), [commit]);
return {
selected,
allSelected,
someSelected,
toggleAll,
toggleRow,
clearSelection,
};
}
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}
</>
);
}
export type {
ButtonLinkProps,
ButtonProps,
ButtonSize,
ButtonVariant,
} from "./base";
export { Button, ButtonLink } from "./base";
export type { MagneticButtonProps } from "./magnetic";
export { MagneticButton } from "./magnetic";
export type { MetallicButtonProps } from "./metallic";
export { MetallicButton } from "./metallic";
export type { ButtonState, StatefulButtonProps } from "./stateful";
export { StatefulButton } from "./stateful";
"use client";
import {
AnimatePresence,
type HTMLMotionProps,
motion,
useReducedMotion,
} from "motion/react";
import {
forwardRef,
type PointerEvent,
type ReactNode,
useCallback,
useRef,
useState,
} from "react";
import { EASE_OUT, SPRING_PRESS } from "@/lib/ease";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
import { cn } from "@/lib/utils";
export type ButtonVariant = "primary" | "secondary" | "ghost" | "outline";
export type ButtonSize = "sm" | "md" | "lg" | "icon";
export interface ButtonProps extends Omit<
HTMLMotionProps<"button">,
"children"
> {
variant?: ButtonVariant;
size?: ButtonSize;
pressScale?: number;
/** Spawn a Material-style ripple from the press point. Off by default. */
ripple?: boolean;
children?: ReactNode;
}
export interface ButtonLinkProps extends Omit<
HTMLMotionProps<"a">,
"children"
> {
variant?: ButtonVariant;
size?: ButtonSize;
pressScale?: number;
children?: ReactNode;
}
type Ripple = { id: number; x: number; y: number; size: number };
const VARIANT_CLASS: Record<ButtonVariant, string> = {
primary: "bg-primary text-primary-foreground hover:bg-primary/90",
secondary: "border border-border bg-card text-foreground hover:border-border",
ghost: "text-muted-foreground hover:text-foreground hover:bg-muted/60",
outline:
"border border-border bg-transparent text-foreground hover:bg-muted/60",
};
const SIZE_CLASS: Record<ButtonSize, string> = {
sm: "h-8 px-3 text-xs gap-1.5 rounded-full",
md: "h-10 px-5 text-sm gap-2 rounded-full",
lg: "h-12 px-6 text-base gap-2 rounded-full",
icon: "h-8 w-8 rounded-lg",
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
function Button(
{
variant = "primary",
size = "md",
pressScale = 0.93,
ripple = false,
className,
children,
onPointerDown,
...rest
},
ref,
) {
const reduce = useReducedMotion();
const canHover = useHoverCapable();
const [ripples, setRipples] = useState<Ripple[]>([]);
const nextId = useRef(0);
const handlePointerDown = useCallback(
(event: PointerEvent<HTMLButtonElement>) => {
if (ripple && !reduce) {
const rect = event.currentTarget.getBoundingClientRect();
const size = Math.max(rect.width, rect.height) * 2;
const id = nextId.current++;
setRipples((prev) => [
...prev,
{
id,
x: event.clientX - rect.left,
y: event.clientY - rect.top,
size,
},
]);
}
onPointerDown?.(event);
},
[ripple, reduce, onPointerDown],
);
return (
<motion.button
ref={ref}
type="button"
whileTap={reduce ? undefined : { scale: pressScale }}
whileHover={reduce || !canHover ? undefined : { scale: 1.02 }}
transition={SPRING_PRESS}
onPointerDown={handlePointerDown}
className={cn(
"inline-flex items-center justify-center font-medium select-none",
"transition-colors",
"disabled:pointer-events-none disabled:opacity-50",
ripple && "relative overflow-hidden",
VARIANT_CLASS[variant],
SIZE_CLASS[size],
className,
)}
{...rest}
>
{ripple && !reduce ? (
<span className="pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit]">
<AnimatePresence>
{ripples.map((r) => (
<motion.span
key={r.id}
className="absolute rounded-full bg-current"
style={{
left: r.x,
top: r.y,
width: r.size,
height: r.size,
x: "-50%",
y: "-50%",
}}
initial={{ scale: 0.05, opacity: 0.3 }}
animate={{ scale: 1, opacity: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 1.6, ease: EASE_OUT }}
onAnimationComplete={() =>
setRipples((prev) => prev.filter((x) => x.id !== r.id))
}
/>
))}
</AnimatePresence>
</span>
) : null}
{children}
</motion.button>
);
},
);
export const ButtonLink = forwardRef<HTMLAnchorElement, ButtonLinkProps>(
function ButtonLink(
{
variant = "primary",
size = "md",
pressScale = 0.93,
className,
children,
...rest
},
ref,
) {
const reduce = useReducedMotion();
const canHover = useHoverCapable();
return (
<motion.a
ref={ref}
whileTap={reduce ? undefined : { scale: pressScale }}
whileHover={reduce || !canHover ? undefined : { scale: 1.02 }}
transition={SPRING_PRESS}
className={cn(
"inline-flex items-center justify-center font-medium select-none",
"transition-colors",
VARIANT_CLASS[variant],
SIZE_CLASS[size],
className,
)}
{...rest}
>
{children}
</motion.a>
);
},
);
"use client";
import { forwardRef } from "react";
import { Magnetic } from "../magnetic";
import { Button, type ButtonProps } from "./base";
export interface MagneticButtonProps extends ButtonProps {
/** Magnetic pull strength. Default 0.25. */
strength?: number;
/** Class applied to the magnetic wrapper. */
magneticClassName?: string;
}
export const MagneticButton = forwardRef<HTMLButtonElement, MagneticButtonProps>(function MagneticButton(
{ strength = 0.25, magneticClassName, children, ...rest },
ref,
) {
return (
<Magnetic strength={strength} className={magneticClassName}>
<Button ref={ref} {...rest}>
{children}
</Button>
</Magnetic>
);
});
"use client";
import { motion, useReducedMotion } from "motion/react";
import { forwardRef, useState } from "react";
import { EASE_IN_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
import { Button, type ButtonProps } from "./base";
export interface MetallicButtonProps extends Omit<
ButtonProps,
"ripple" | "variant"
> {
/** Stops the traveling reflection while preserving the chrome rim. */
paused?: boolean;
}
// The rim and highlight drift separately so the material stays quiet and reflective.
const SILVER_DRIFT = {
duration: 8,
ease: EASE_IN_OUT,
repeat: Infinity,
};
const CHROME_SHIMMER = {
duration: 2.4,
ease: EASE_IN_OUT,
};
export const MetallicButton = forwardRef<
HTMLButtonElement,
MetallicButtonProps
>(function MetallicButton(
{
size = "md",
paused = false,
className,
children,
onHoverStart,
onHoverEnd,
...rest
},
ref,
) {
const reduce = useReducedMotion();
const still = paused || Boolean(reduce);
const [hovered, setHovered] = useState(false);
return (
<Button
ref={ref}
variant="ghost"
size={size}
onHoverStart={(event, info) => {
setHovered(true);
onHoverStart?.(event, info);
}}
onHoverEnd={(event, info) => {
setHovered(false);
onHoverEnd?.(event, info);
}}
className={cn(
"group relative isolate overflow-hidden border-0 bg-transparent text-foreground",
"hover:bg-transparent hover:text-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"shadow-[0_8px_22px_rgba(0,0,0,0.16)]",
size === "icon" && "rounded-full",
className,
)}
{...rest}
>
<motion.span
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 left-[-18%] z-0 w-[136%] rounded-[inherit] bg-[linear-gradient(105deg,#111_0%,#737373_14%,#fafafa_26%,#525252_38%,#0a0a0a_50%,#a3a3a3_64%,#fff_75%,#404040_87%,#111_100%)]"
animate={still ? undefined : { x: ["0%", "13%", "0%"] }}
transition={still ? undefined : SILVER_DRIFT}
/>
<motion.span
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 left-[-58%] z-[1] w-[52%] -skew-x-12 bg-[linear-gradient(90deg,transparent,rgba(255,255,255,0.5)_48%,transparent)] opacity-50 blur-[3px] mix-blend-screen"
animate={still ? undefined : { x: hovered ? "310%" : "0%" }}
transition={still ? undefined : CHROME_SHIMMER}
/>
<span
aria-hidden="true"
className="pointer-events-none absolute inset-[2px] z-[2] rounded-[inherit] bg-background transition-colors group-hover:bg-muted/40"
/>
<span
aria-hidden="true"
className="pointer-events-none absolute inset-[2px] z-[3] rounded-[inherit] shadow-[inset_0_1px_0_rgba(255,255,255,0.28),inset_0_-1px_0_rgba(0,0,0,0.16)]"
/>
<span className="relative z-10 inline-flex items-center justify-center gap-2">
{children}
</span>
</Button>
);
});
"use client";
import { Check, Loader2, X } from "lucide-react";
import {
AnimatePresence,
motion,
useReducedMotion,
type Variants,
} from "motion/react";
import {
forwardRef,
type ReactNode,
useLayoutEffect,
useRef,
useState,
} from "react";
import { EASE_OUT, SPRING_SWAP } from "@/lib/ease";
import { Button, type ButtonProps } from "./base";
export type ButtonState = "idle" | "loading" | "success" | "error";
export interface StatefulButtonProps extends Omit<ButtonProps, "children"> {
state?: ButtonState;
children: ReactNode;
loadingText?: ReactNode;
successText?: ReactNode;
errorText?: ReactNode;
icon?: ReactNode;
}
const CASCADE_STAGGER = 0.025;
const ROLL_BLUR = "blur(6px)";
const CASCADE_LETTER_VARIANTS: Variants = {
initial: { opacity: 0, y: "105%", filter: ROLL_BLUR },
animate: (delay: number = 0) => ({
opacity: 1,
y: "0%",
filter: "blur(0px)",
transition: { ...SPRING_SWAP, delay },
}),
exit: (delay: number = 0) => ({
opacity: 0,
y: "-105%",
filter: ROLL_BLUR,
transition: { duration: 0.16, ease: EASE_OUT, delay: delay * 0.5 },
}),
};
const ICON_VARIANTS: Variants = {
// Width collapses too, so the icon adds/removes its own space smoothly
// instead of popping the row width in a single frame.
initial: { opacity: 0, width: 0, scale: 0.7, filter: ROLL_BLUR },
animate: {
opacity: 1,
width: "1.5rem",
scale: 1,
filter: "blur(0px)",
transition: SPRING_SWAP,
},
exit: {
opacity: 0,
width: 0,
scale: 0.7,
filter: ROLL_BLUR,
transition: { duration: 0.16, ease: EASE_OUT },
},
};
function IconSlot({ keyId, children }: { keyId: string; children: ReactNode }) {
const reduce = useReducedMotion();
return (
<motion.span
key={keyId}
variants={ICON_VARIANTS}
initial={reduce ? { opacity: 0 } : "initial"}
animate={reduce ? { opacity: 1 } : "animate"}
exit={reduce ? { opacity: 0 } : "exit"}
transition={reduce ? { duration: 0.15 } : undefined}
className="inline-grid shrink-0 place-items-center overflow-hidden"
>
{children}
</motion.span>
);
}
function TextSlot({
value,
children,
}: {
value: string;
children: ReactNode;
}) {
const reduce = useReducedMotion();
const measureRef = useRef<HTMLSpanElement>(null);
const [width, setWidth] = useState<number>();
const label = typeof children === "string" ? children : null;
const cascade = label !== null && !reduce;
// Measure strings with the same per-letter layout as the cascade. Measuring
// the whole string preserves kerning, which can make it narrower than the
// inline-block letters and clip the final glyph during the width animation.
useLayoutEffect(() => {
const nextWidth = measureRef.current?.offsetWidth;
if (!nextWidth) return;
setWidth((current) => (current === nextWidth ? current : nextWidth));
});
return (
<motion.span
initial={false}
animate={{ width }}
transition={reduce ? { duration: 0 } : SPRING_SWAP}
className="relative inline-block overflow-hidden whitespace-nowrap align-bottom"
>
<span
ref={measureRef}
aria-hidden
className="invisible inline-block whitespace-nowrap"
>
{cascade
? label.split("").map((char, index) => (
<span
// biome-ignore lint/suspicious/noArrayIndexKey: position is the slot identity.
key={index}
className="inline-block whitespace-pre"
>
{char}
</span>
))
: children}
</span>
{cascade ? (
<>
<span className="sr-only">{label}</span>
<AnimatePresence initial={false}>
<motion.span
key={`cascade-${value}`}
aria-hidden
initial="initial"
animate="animate"
exit="exit"
className="absolute left-0 top-0 inline-block whitespace-pre"
>
{label.split("").map((char, index) => (
<motion.span
// biome-ignore lint/suspicious/noArrayIndexKey: position is the slot identity.
key={index}
custom={index * CASCADE_STAGGER}
variants={CASCADE_LETTER_VARIANTS}
className="inline-block whitespace-pre will-change-[opacity,filter,transform]"
>
{char}
</motion.span>
))}
</motion.span>
</AnimatePresence>
</>
) : (
<AnimatePresence initial={false}>
<motion.span
key={`text-${value}`}
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 14, filter: ROLL_BLUR }}
animate={reduce ? { opacity: 1 } : { opacity: 1, y: 0, filter: "blur(0px)" }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: -14, filter: ROLL_BLUR }}
transition={reduce ? { duration: 0.15 } : SPRING_SWAP}
className="absolute left-0 top-0 inline-block will-change-[opacity,filter,transform]"
>
{children}
</motion.span>
</AnimatePresence>
)}
</motion.span>
);
}
export const StatefulButton = forwardRef<HTMLButtonElement, StatefulButtonProps>(function StatefulButton(
{
state = "idle",
children,
loadingText = "Loading",
successText = "Done",
errorText = "Try again",
icon,
disabled,
...rest
},
ref,
) {
const isBusy = state === "loading";
const stateText =
state === "loading"
? loadingText
: state === "success"
? successText
: state === "error"
? errorText
: children;
const textKey =
typeof stateText === "string" ? `${state}-${stateText}` : state;
return (
<Button ref={ref} disabled={disabled || isBusy} aria-busy={isBusy} whileHover={undefined} {...rest}>
<span
aria-live="polite"
className="relative inline-flex items-center justify-center overflow-hidden"
>
<AnimatePresence initial={false}>
{state === "loading" ? (
<IconSlot keyId="loading-icon">
<Loader2 className="h-4 w-4 animate-spin" />
</IconSlot>
) : null}
{state === "success" ? (
<IconSlot keyId="success-icon">
<Check className="h-4 w-4" />
</IconSlot>
) : null}
{state === "error" ? (
<IconSlot keyId="error-icon">
<X className="h-4 w-4" />
</IconSlot>
) : null}
</AnimatePresence>
<TextSlot value={textKey}>{stateText}</TextSlot>
<AnimatePresence initial={false}>
{state === "idle" && icon ? (
<IconSlot keyId="idle-icon">{icon}</IconSlot>
) : null}
</AnimatePresence>
</span>
</Button>
);
});
"use client";
import { motion, useMotionValue, useReducedMotion, useSpring } from "motion/react";
import { useRef, type ReactNode } from "react";
import { SPRING_MOUSE } from "@/lib/ease";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
import { cn } from "@/lib/utils";
export interface MagneticProps {
children: ReactNode;
strength?: number;
className?: string;
}
export function Magnetic({ children, strength = 0.35, className }: MagneticProps) {
const ref = useRef<HTMLDivElement>(null);
const reduce = useReducedMotion();
const canHover = useHoverCapable();
// Decorative cursor-follow: skip on touch (phantom hover) and reduced motion.
const enabled = !reduce && canHover;
const x = useMotionValue(0);
const y = useMotionValue(0);
const sx = useSpring(x, SPRING_MOUSE);
const sy = useSpring(y, SPRING_MOUSE);
const onMove = (e: React.MouseEvent<HTMLDivElement>) => {
const el = ref.current;
if (!el || !enabled) return;
const rect = el.getBoundingClientRect();
x.set((e.clientX - rect.left - rect.width / 2) * strength);
y.set((e.clientY - rect.top - rect.height / 2) * strength);
};
const onLeave = () => {
x.set(0);
y.set(0);
};
return (
<motion.div
ref={ref}
onMouseMove={onMove}
onMouseLeave={onLeave}
style={{ x: sx, y: sy }}
className={cn("inline-block", className)}
>
{children}
</motion.div>
);
}
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";
// asharca.github.io/ui/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,
selectionActions,
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,
clearSelection,
} = useRowSelection({
sortedRows,
selectedRowIds,
defaultSelectedRowIds,
onSelectionChange,
});
const selectedEntries = useMemo(
() =>
selectable && selectionActions
? sortedRows.filter((entry) => selected.has(entry.id))
: [],
[selectable, selectionActions, sortedRows, selected],
);
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}
selectedCount={selectedEntries.length}
selectionActions={
selectedEntries.length > 0
? selectionActions?.({
selectedRows: selectedEntries.map((entry) => entry.row),
selectedRowIds: selectedEntries.map((entry) => entry.id),
clearSelection,
})
: undefined
}
onClearSelection={clearSelection}
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-muted/60",
"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";
// asharca.github.io/ui/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,
selectionActions,
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,
clearSelection,
} = useRowSelection({
sortedRows,
selectedRowIds,
defaultSelectedRowIds,
onSelectionChange,
});
const selectedEntries = useMemo(
() =>
selectable && selectionActions
? sortedRows.filter((entry) => selected.has(entry.id))
: [],
[selectable, selectionActions, sortedRows, selected],
);
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}
selectedCount={selectedEntries.length}
selectionActions={
selectedEntries.length > 0
? selectionActions?.({
selectedRows: selectedEntries.map((entry) => entry.row),
selectedRowIds: selectedEntries.map((entry) => entry.id),
clearSelection,
})
: undefined
}
onClearSelection={clearSelection}
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-muted/60",
"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,
type ReactNode,
useEffect,
useLayoutEffect,
useRef,
} 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,
CHECKBOX_PX,
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;
selectedCount: number;
selectionActions?: ReactNode;
onClearSelection: () => 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,
selectedCount,
selectionActions,
onClearSelection,
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);
const showSelection = selectable && selectedCount > 0;
const selectionHadFocus = useRef(false);
const checkboxRef = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
if (!showSelection && selectionHadFocus.current) {
if (document.activeElement === document.body) {
checkboxRef.current?.querySelector("button")?.focus();
}
selectionHadFocus.current = false;
}
}, [showSelection]);
return (
<>
{!showSelection && hasColumnMenu && activeColumn && activeIndex >= 0 ? (
<ColumnHandle
column={columns[activeIndex]}
index={activeIndex}
thRefs={thRefs}
onInsertColumn={onInsertColumn}
onDeleteColumn={onDeleteColumn}
onEnter={() => onColumnActivate?.(activeColumn)}
onLeave={() => onColumnDeactivate?.()}
/>
) : null}
<thead className="sticky top-0 z-10">
<tr style={{ height: rowHeight }}>
{selectable ? (
<th className="sticky top-0 z-10 border-border border-b bg-muted">
<div
ref={checkboxRef}
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 = !showSelection && activeColumn === column.key;
return (
<th
key={column.key}
ref={(el) => {
thRefs.current[column.key] = el;
}}
onPointerEnter={
showSelection
? undefined
: () => 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",
)}
>
{showSelection ? (
<span className="sr-only">{column.header}</span>
) : null}
<motion.div
inert={showSelection}
className={cn(
"flex h-full items-center",
alignFlex(column.align),
)}
style={{
height: rowHeight,
visibility: showSelection ? "hidden" : undefined,
}}
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 && !showSelection ? (
<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-muted",
// 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>
{showSelection ? (
<tr
className="absolute inset-y-0 right-0 z-20"
style={{ left: CHECKBOX_PX }}
>
<td
colSpan={columns.length + 1}
className="block h-full bg-muted p-0"
>
<motion.div
onFocusCapture={() => {
selectionHadFocus.current = true;
}}
onBlurCapture={(event) => {
selectionHadFocus.current = event.currentTarget.contains(
event.relatedTarget,
);
}}
role="group"
aria-label="Selection actions"
initial={{ opacity: 0, y: reduce ? 0 : 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.16, ease: EASE_OUT }}
className="flex h-full items-center gap-3 overflow-x-auto border-border border-b px-4 font-normal"
>
<span
role="status"
className="shrink-0 font-medium tabular-nums"
>
{selectedCount} selected
</span>
<div className="flex items-center gap-2">
{selectionActions}
</div>
<button
type="button"
onClick={onClearSelection}
className="ml-auto shrink-0 rounded-md px-2 py-1 text-muted-foreground transition-colors hover:bg-background hover:text-foreground focus-visible:outline-2 focus-visible:outline-ring"
>
Clear selection
</button>
</motion.div>
</td>
</tr>
) : null}
</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;
/** Replaces column controls while loaded rows are selected; requires `selectable`.
* Rows/ids follow the current sort and exclude ids absent from `data`.
* `clearSelection` clears every selected id, including unloaded rows. */
selectionActions?: (selection: {
selectedRows: T[];
selectedRowIds: string[];
clearSelection: () => void;
}) => ReactNode;
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],
);
const clearSelection = useCallback(() => commit(new Set()), [commit]);
return {
selected,
allSelected,
someSelected,
toggleAll,
toggleRow,
clearSelection,
};
}
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";
// asharca.github.io/ui/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,
selectionActions,
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,
clearSelection,
} = useRowSelection({
sortedRows,
selectedRowIds,
defaultSelectedRowIds,
onSelectionChange,
});
const selectedEntries = useMemo(
() =>
selectable && selectionActions
? sortedRows.filter((entry) => selected.has(entry.id))
: [],
[selectable, selectionActions, sortedRows, selected],
);
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}
selectedCount={selectedEntries.length}
selectionActions={
selectedEntries.length > 0
? selectionActions?.({
selectedRows: selectedEntries.map((entry) => entry.row),
selectedRowIds: selectedEntries.map((entry) => entry.id),
clearSelection,
})
: undefined
}
onClearSelection={clearSelection}
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-muted/60",
"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";
// asharca.github.io/ui/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,
selectionActions,
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,
clearSelection,
} = useRowSelection({
sortedRows,
selectedRowIds,
defaultSelectedRowIds,
onSelectionChange,
});
const selectedEntries = useMemo(
() =>
selectable && selectionActions
? sortedRows.filter((entry) => selected.has(entry.id))
: [],
[selectable, selectionActions, sortedRows, selected],
);
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}
selectedCount={selectedEntries.length}
selectionActions={
selectedEntries.length > 0
? selectionActions?.({
selectedRows: selectedEntries.map((entry) => entry.row),
selectedRowIds: selectedEntries.map((entry) => entry.id),
clearSelection,
})
: undefined
}
onClearSelection={clearSelection}
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-muted/60",
"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,
type ReactNode,
useEffect,
useLayoutEffect,
useRef,
} 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,
CHECKBOX_PX,
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;
selectedCount: number;
selectionActions?: ReactNode;
onClearSelection: () => 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,
selectedCount,
selectionActions,
onClearSelection,
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);
const showSelection = selectable && selectedCount > 0;
const selectionHadFocus = useRef(false);
const checkboxRef = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
if (!showSelection && selectionHadFocus.current) {
if (document.activeElement === document.body) {
checkboxRef.current?.querySelector("button")?.focus();
}
selectionHadFocus.current = false;
}
}, [showSelection]);
return (
<>
{!showSelection && hasColumnMenu && activeColumn && activeIndex >= 0 ? (
<ColumnHandle
column={columns[activeIndex]}
index={activeIndex}
thRefs={thRefs}
onInsertColumn={onInsertColumn}
onDeleteColumn={onDeleteColumn}
onEnter={() => onColumnActivate?.(activeColumn)}
onLeave={() => onColumnDeactivate?.()}
/>
) : null}
<thead className="sticky top-0 z-10">
<tr style={{ height: rowHeight }}>
{selectable ? (
<th className="sticky top-0 z-10 border-border border-b bg-muted">
<div
ref={checkboxRef}
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 = !showSelection && activeColumn === column.key;
return (
<th
key={column.key}
ref={(el) => {
thRefs.current[column.key] = el;
}}
onPointerEnter={
showSelection
? undefined
: () => 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",
)}
>
{showSelection ? (
<span className="sr-only">{column.header}</span>
) : null}
<motion.div
inert={showSelection}
className={cn(
"flex h-full items-center",
alignFlex(column.align),
)}
style={{
height: rowHeight,
visibility: showSelection ? "hidden" : undefined,
}}
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 && !showSelection ? (
<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-muted",
// 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>
{showSelection ? (
<tr
className="absolute inset-y-0 right-0 z-20"
style={{ left: CHECKBOX_PX }}
>
<td
colSpan={columns.length + 1}
className="block h-full bg-muted p-0"
>
<motion.div
onFocusCapture={() => {
selectionHadFocus.current = true;
}}
onBlurCapture={(event) => {
selectionHadFocus.current = event.currentTarget.contains(
event.relatedTarget,
);
}}
role="group"
aria-label="Selection actions"
initial={{ opacity: 0, y: reduce ? 0 : 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.16, ease: EASE_OUT }}
className="flex h-full items-center gap-3 overflow-x-auto border-border border-b px-4 font-normal"
>
<span
role="status"
className="shrink-0 font-medium tabular-nums"
>
{selectedCount} selected
</span>
<div className="flex items-center gap-2">
{selectionActions}
</div>
<button
type="button"
onClick={onClearSelection}
className="ml-auto shrink-0 rounded-md px-2 py-1 text-muted-foreground transition-colors hover:bg-background hover:text-foreground focus-visible:outline-2 focus-visible:outline-ring"
>
Clear selection
</button>
</motion.div>
</td>
</tr>
) : null}
</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;
/** Replaces column controls while loaded rows are selected; requires `selectable`.
* Rows/ids follow the current sort and exclude ids absent from `data`.
* `clearSelection` clears every selected id, including unloaded rows. */
selectionActions?: (selection: {
selectedRows: T[];
selectedRowIds: string[];
clearSelection: () => void;
}) => ReactNode;
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],
);
const clearSelection = useCallback(() => commit(new Set()), [commit]);
return {
selected,
allSelected,
someSelected,
toggleAll,
toggleRow,
clearSelection,
};
}
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)—selectionActions?((selection: { selectedRows: T[]; selectedRowIds: string[]; clearSelection: () => void; }) => ReactNode)Replaces column controls while loaded rows are selected; requires `selectable`. Rows/ids follow the current sort and exclude ids absent from `data`. `clearSelection` clears every selected id, including unloaded rows.
—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—Related components
Input
Text input with label, left/right icons, optional stable error row, error shake and success check draw.
Select
Composable select primitives whose panel bouncily unfolds out of the trigger and separates, plus a Morph variant where the trigger grows into the panel via shared layout.
Checkbox
Form choice control with a draw-on checkmark, spring press feedback and indeterminate state support.
Updated