Data Grid Lite
Data Display

Data Grid Lite

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.

Install

npx shadcn@latest add @paragon/data-grid-lite

data-grid-lite.tsx

"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>
  );
}