A table with pointer-captured resizable columns (min-widths, double-click auto-fit, keyboard nudge), left-pinned sticky columns with a scroll-aware shadow seam, and animated sortable headers.
npx shadcn@latest add @paragon/data-grid-lite"use client";
import * as React from "react";
import {
AnimatePresence,
motion,
useInView,
useReducedMotion,
} from "motion/react";
import { ArrowDown, ArrowUp, Database } from "lucide-react";
import { cn } from "@/lib/utils";
type SortDirection = "asc" | "desc";
type Density = "comfortable" | "compact";
export interface DataGridColumn<T> {
/** Unique column id. Also the default row property for cell values. */
key: string;
header: React.ReactNode;
/** Initial width in px. Default 160. */
width?: number;
/** Resize floor in px. Default 72. */
minWidth?: number;
align?: "left" | "right";
/** Pins the column to the left edge; pinned columns order first. */
pinned?: boolean;
sortable?: boolean;
/** Reads the sortable/displayable value. Defaults to `row[key]`. */
accessor?: (row: T) => string | number | null | undefined;
/** Custom cell renderer. Falls back to the accessor value. */
render?: (row: T) => React.ReactNode;
}
export interface DataGridLiteProps<T>
extends Omit<React.ComponentProps<"div">, "children"> {
columns: DataGridColumn<T>[];
rows: readonly T[];
getRowKey: (row: T, index: number) => React.Key;
density?: Density;
defaultSort?: { key: string; direction: SortDirection };
onSortChange?: (sort: { key: string; direction: SortDirection } | null) => void;
maxHeight?: number;
loading?: boolean;
emptyState?: React.ReactNode;
/** Disables the sort icon swap and row reveal. */
static?: boolean;
}
const HEAD_DENSITY: Record<Density, string> = {
comfortable: "h-10 px-3",
compact: "h-8 px-2.5",
};
const CELL_DENSITY: Record<Density, string> = {
comfortable: "px-3 py-2.5",
compact: "px-2.5 py-1.5",
};
const CELL_PAD: Record<Density, number> = { comfortable: 26, compact: 22 };
function defaultAccessor<T>(row: T, key: string) {
return (row as Record<string, unknown>)[key] as
| string
| number
| null
| undefined;
}
/**
* A table with resizable columns, left pinning, and sorting — the dense
* middle ground below a full data-grid dependency. Header seams are
* pointer-captured drag handles (Arrow keys nudge, Enter or double-click
* auto-fits to content); pinned columns are position-sticky with an opacity-
* faded shadow seam that appears only once the body has scrolled under them.
* Rows reveal with an opacity-only stagger — transforms would break the
* sticky cells inside them.
*/
export function DataGridLite<T>({
columns,
rows,
getRowKey,
density = "comfortable",
defaultSort,
onSortChange,
maxHeight = 384,
loading = false,
emptyState,
static: isStatic = false,
className,
...props
}: DataGridLiteProps<T>) {
const scrollRef = React.useRef<HTMLDivElement>(null);
const inView = useInView(scrollRef, { once: true, margin: "0px 0px -32px 0px" });
const reducedMotion = useReducedMotion() ?? false;
const shown = isStatic || reducedMotion || inView;
const [overrides, setOverrides] = React.useState<Record<string, number>>({});
const [sort, setSort] = React.useState<{ key: string; direction: SortDirection } | null>(
defaultSort ?? null,
);
const [scrolled, setScrolled] = React.useState(false);
const [resizingKey, setResizingKey] = React.useState<string | null>(null);
// Pinned columns order first; sort() is stable so relative order holds.
const ordered = React.useMemo(
() => [...columns].sort((a, b) => Number(!!b.pinned) - Number(!!a.pinned)),
[columns],
);
const widthOf = React.useCallback(
(column: DataGridColumn<T>) =>
Math.max(
overrides[column.key] ?? column.width ?? 160,
column.minWidth ?? 72,
),
[overrides],
);
// Cumulative sticky offsets for pinned columns.
const lefts = React.useMemo(() => {
const map: Record<string, number> = {};
let acc = 0;
for (const column of ordered) {
if (!column.pinned) break;
map[column.key] = acc;
acc += widthOf(column);
}
return map;
}, [ordered, widthOf]);
const lastPinnedKey = [...ordered].reverse().find((c) => c.pinned)?.key;
const totalWidth = ordered.reduce((sum, c) => sum + widthOf(c), 0);
const setWidth = (key: string, next: number, minWidth: number) => {
setOverrides((prev) => ({ ...prev, [key]: Math.max(next, minWidth) }));
};
const autoFit = (column: DataGridColumn<T>) => {
const container = scrollRef.current;
if (!container) return;
const cells = container.querySelectorAll<HTMLElement>(
`[data-grid-cell="${column.key}"] [data-grid-content]`,
);
let max = 0;
cells.forEach((cell) => {
max = Math.max(max, cell.scrollWidth);
});
const allowance = CELL_PAD[density] + (column.sortable ? 20 : 0);
setWidth(column.key, Math.min(max + allowance, 560), column.minWidth ?? 72);
};
const toggleSort = (column: DataGridColumn<T>) => {
setSort((prev) => {
const next: { key: string; direction: SortDirection } | null =
prev?.key !== column.key
? { key: column.key, direction: "asc" }
: prev.direction === "asc"
? { key: column.key, direction: "desc" }
: null;
onSortChange?.(next);
return next;
});
};
const sortedRows = React.useMemo(() => {
if (!sort) return rows;
const column = columns.find((c) => c.key === sort.key);
if (!column) return rows;
const accessor = column.accessor ?? ((row: T) => defaultAccessor(row, column.key));
const dir = sort.direction === "asc" ? 1 : -1;
return [...rows].sort((a, b) => {
const va = accessor(a);
const vb = accessor(b);
if (va == null && vb == null) return 0;
if (va == null) return 1;
if (vb == null) return -1;
if (typeof va === "number" && typeof vb === "number") return (va - vb) * dir;
return String(va).localeCompare(String(vb)) * dir;
});
}, [rows, sort, columns]);
const empty = !loading && rows.length === 0;
return (
<div
data-slot="data-grid-lite"
className={cn("w-full", className)}
{...props}
>
<div
ref={scrollRef}
role="region"
aria-label="Data grid"
tabIndex={0}
onScroll={(event) => {
const next = event.currentTarget.scrollLeft > 0;
setScrolled((prev) => (prev === next ? prev : next));
}}
className={cn(
"relative w-full overflow-auto overscroll-x-contain rounded-xl bg-card shadow-border",
"outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
resizingKey && "cursor-col-resize select-none",
)}
style={{ maxHeight }}
>
<table
className="caption-bottom border-separate border-spacing-0 text-sm"
style={{ width: totalWidth, tableLayout: "fixed" }}
>
<colgroup>
{ordered.map((column) => (
<col key={column.key} style={{ width: widthOf(column) }} />
))}
</colgroup>
<thead>
<tr>
{ordered.map((column) => {
const width = widthOf(column);
const direction = sort?.key === column.key ? sort.direction : null;
return (
<th
key={column.key}
data-grid-cell={column.key}
aria-sort={
direction === "asc"
? "ascending"
: direction === "desc"
? "descending"
: undefined
}
className={cn(
"sticky top-0 z-20 border-b bg-card text-left align-middle text-xs font-medium whitespace-nowrap text-muted-foreground",
HEAD_DENSITY[density],
column.align === "right" && "text-right",
column.pinned && "z-30",
)}
style={column.pinned ? { position: "sticky", left: lefts[column.key] } : undefined}
>
{column.sortable ? (
<button
type="button"
onClick={() => toggleSort(column)}
className={cn(
"group -mx-1 inline-flex max-w-full items-center gap-1 rounded-sm px-1 py-0.5 align-middle transition-colors duration-(--duration-fast) select-none hover:text-foreground",
column.align === "right" && "flex-row-reverse",
)}
>
<span data-grid-content className="truncate">
{column.header}
</span>
<SortArrow
direction={direction}
reduced={reducedMotion || isStatic}
/>
</button>
) : (
<span data-grid-content className="block truncate">
{column.header}
</span>
)}
{column.key === lastPinnedKey && <SeamShadow scrolled={scrolled} />}
<ResizeHandle
label={typeof column.header === "string" ? column.header : column.key}
width={width}
minWidth={column.minWidth ?? 72}
active={resizingKey === column.key}
onResizeStart={() => setResizingKey(column.key)}
onResizeEnd={() => setResizingKey(null)}
onResize={(next) => setWidth(column.key, next, column.minWidth ?? 72)}
onAutoFit={() => autoFit(column)}
/>
</th>
);
})}
</tr>
</thead>
<tbody>
{loading &&
Array.from({ length: 5 }, (_, i) => (
<tr key={`skeleton-${i}`} aria-hidden>
{ordered.map((column) => (
<td
key={column.key}
className={cn("border-b", CELL_DENSITY[density])}
style={
column.pinned
? { position: "sticky", left: lefts[column.key], zIndex: 10 }
: undefined
}
>
<div
className="h-2.5 rounded-sm bg-muted animate-pulse motion-reduce:animate-none"
style={{
width: `${[70, 45, 80, 55, 65][(i + column.key.length) % 5]}%`,
animationDelay: `${i * 80}ms`,
}}
/>
</td>
))}
</tr>
))}
{!loading &&
sortedRows.map((row, rowIndex) => (
<tr
key={getRowKey(row, rowIndex)}
className="group/row"
style={
shown && !reducedMotion && !isStatic
? { transitionDelay: `${Math.min(rowIndex, 12) * 35}ms` }
: undefined
}
>
{ordered.map((column) => {
const value = (column.accessor ?? ((r: T) => defaultAccessor(r, column.key)))(row);
const content = column.render ? (
column.render(row)
) : value == null ? (
<span className="text-muted-foreground/60">—</span>
) : (
value
);
return (
<td
key={column.key}
data-grid-cell={column.key}
className={cn(
"relative border-b align-middle whitespace-nowrap",
"transition-[background-color,opacity] duration-(--duration-base) ease-(--ease-out)",
CELL_DENSITY[density],
shown ? "opacity-100" : "opacity-0",
column.align === "right" && "text-right tabular-nums",
column.pinned
? "sticky z-10 bg-card"
: "group-hover/row:bg-muted/40",
)}
style={column.pinned ? { left: lefts[column.key] } : undefined}
>
{column.pinned && (
<span
aria-hidden
className="pointer-events-none absolute inset-0 bg-muted/40 opacity-0 transition-opacity duration-(--duration-fast) group-hover/row:opacity-100"
/>
)}
<span
data-grid-content
className="relative block truncate"
title={typeof value === "string" ? value : undefined}
>
{content}
</span>
{column.key === lastPinnedKey && (
<SeamShadow scrolled={scrolled} />
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
{empty && (
<div className="flex flex-col items-center gap-1.5 px-4 py-12 text-center">
{emptyState ?? (
<>
<Database aria-hidden className="size-4 text-muted-foreground/60" />
<p className="text-xs text-muted-foreground">No rows to display</p>
</>
)}
</div>
)}
</div>
</div>
);
}
function SeamShadow({ scrolled }: { scrolled: boolean }) {
return (
<span
aria-hidden
className={cn(
"pointer-events-none absolute inset-y-0 left-full w-3 bg-gradient-to-r from-foreground/[0.08] to-transparent",
"transition-opacity duration-(--duration-base) ease-(--ease-out)",
scrolled ? "opacity-100" : "opacity-0",
)}
/>
);
}
function SortArrow({
direction,
reduced,
}: {
direction: SortDirection | null;
reduced: boolean;
}) {
const arrow = (
<span className="flex size-3.5 items-center justify-center" aria-hidden>
{direction === "desc" ? (
<ArrowDown className="size-3" />
) : (
<ArrowUp
className={cn(
"size-3",
!direction &&
"opacity-0 transition-opacity duration-(--duration-fast) group-hover:opacity-50 group-focus-visible:opacity-50",
)}
/>
)}
</span>
);
if (reduced) return arrow;
return (
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={direction ?? "none"}
className="flex shrink-0"
initial={{ opacity: 0, scale: 0.25, filter: "blur(4px)" }}
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
exit={{ opacity: 0, scale: 0.25, filter: "blur(4px)" }}
transition={{ type: "spring", duration: 0.3, bounce: 0 }}
>
{arrow}
</motion.span>
</AnimatePresence>
);
}
function ResizeHandle({
label,
width,
minWidth,
active,
onResize,
onResizeStart,
onResizeEnd,
onAutoFit,
}: {
label: string;
width: number;
minWidth: number;
active: boolean;
onResize: (width: number) => void;
onResizeStart: () => void;
onResizeEnd: () => void;
onAutoFit: () => void;
}) {
const origin = React.useRef<{ x: number; width: number } | null>(null);
return (
<div
role="separator"
aria-orientation="vertical"
aria-label={`Resize ${label} column`}
aria-valuenow={Math.round(width)}
aria-valuemin={minWidth}
tabIndex={0}
onPointerDown={(event) => {
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
origin.current = { x: event.clientX, width };
onResizeStart();
}}
onPointerMove={(event) => {
if (!origin.current) return;
onResize(origin.current.width + (event.clientX - origin.current.x));
}}
onPointerUp={(event) => {
event.currentTarget.releasePointerCapture(event.pointerId);
origin.current = null;
onResizeEnd();
}}
onPointerCancel={() => {
origin.current = null;
onResizeEnd();
}}
onDoubleClick={onAutoFit}
onKeyDown={(event) => {
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
event.preventDefault();
onResize(width + (event.key === "ArrowRight" ? 12 : -12));
} else if (event.key === "Enter") {
event.preventDefault();
onAutoFit();
}
}}
className="group/handle absolute inset-y-0 -right-1 z-10 w-2 cursor-col-resize touch-none outline-none"
>
<span
aria-hidden
className={cn(
"absolute inset-y-1 left-1/2 w-[3px] -translate-x-1/2 rounded-full",
"transition-[background-color,opacity] duration-(--duration-fast)",
active
? "bg-ring"
: "bg-transparent group-hover/handle:bg-border group-focus-visible/handle:bg-ring",
)}
/>
</div>
);
}