For the complete documentation index, see llms.txt. Markdown variants are available by appending .md to documentation URLs. Homepage markdown is available at /llms.md/content.md. An agent skill is available at /.well-known/agent-skills/site-skill.md.

Data Grid

An editable spreadsheet-style grid for structured data.

Use arrow keys to move between cells, Shift with arrow keys to select a range, and Enter or F2 to edit. Copy and paste work with spreadsheet data.

Installation

$ pnpm dlx shadcn@latest add @cobalt/data-grid

Source

"use client";

import {
  RiArrowDownLine,
  RiArrowUpLine,
  RiDownloadLine,
  RiArrowGoForwardLine,
  RiArrowGoBackLine,
} from "@remixicon/react";
import { cn } from "cn";
import { motion, useReducedMotion } from "motion/react";
import * as React from "react";

import {
  applyGridEdits,
  columnLetter,
  parseGridClipboard,
  parseGridValue,
  serializeGridRows,
} from "@/components/lib/data-grid-utils";
import type { GridEdit } from "@/components/lib/data-grid-utils";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/popover";

export interface DataGridColumn<T> {
  key: Extract<keyof T, string>;
  header: string;
  width?: number;
  type?: "text" | "number" | "select";
  options?: readonly string[];
  editable?: boolean;
  filterable?: boolean;
  min?: number;
  max?: number;
  align?: "start" | "end";
  summary?: "sum" | "average" | "count";
  format?: (value: T[keyof T]) => React.ReactNode;
  formatSummary?: (value: number) => React.ReactNode;
  validate?: (value: string | number, row: T) => string | undefined;
}
export interface DataGridProps<T extends object> {
  "aria-label": string;
  data: readonly T[];
  columns: readonly DataGridColumn<T>[];
  getRowId: (row: T) => string;
  onDataChange?: (data: T[]) => void;
  height?: number;
  className?: string;
  exportFileName?: string;
  emptyState?: React.ReactNode;
  direction?: "ltr" | "rtl";
  popupClassName?: string;
}
type Transaction<T> =
  | { type: "edit"; edits: GridEdit<T>[] }
  | { type: "delete"; rows: { row: T; index: number }[] };
interface Cell {
  rowId: string;
  key: string;
}

export function DataGrid<T extends object>({
  "aria-label": label,
  data,
  columns,
  getRowId,
  onDataChange,
  height = 396,
  className,
  exportFileName = "data-grid.csv",
  emptyState = "No matching rows.",
  direction = "ltr",
  popupClassName,
}: DataGridProps<T>) {
  const id = React.useId();
  const grid = React.useRef<HTMLDivElement>(null);
  const body = React.useRef<HTMLDivElement>(null);
  const summaryRow = React.useRef<HTMLDivElement>(null);
  const reduced = useReducedMotion();
  const [menu, setMenu] = React.useState<"filters" | "columns" | null>(null);
  const [viewportWidth, setViewportWidth] = React.useState(0);
  React.useEffect(() => {
    if (!body.current) {
      return;
    }
    const observer = new ResizeObserver(([entry]) =>
      setViewportWidth(entry.contentRect.width)
    );
    observer.observe(body.current);
    return () => observer.disconnect();
  }, []);
  const [query, setQuery] = React.useState("");
  const [sort, setSort] = React.useState<{
    key: string;
    descending: boolean;
  } | null>(null);
  const [filters, setFilters] = React.useState<Record<string, string>>({});
  const [hidden, setHidden] = React.useState<string[]>([]);
  const [compact, setCompact] = React.useState(false);
  const [widths, setWidths] = React.useState<Record<string, number>>({});
  const [selected, setSelected] = React.useState<string[]>([]);
  const [range, setRange] = React.useState<{
    anchor: Cell;
    focus: Cell;
  } | null>(() => {
    if (!data.length || !columns.length) {
      return null;
    }
    const cell = { rowId: getRowId(data[0]), key: columns[0].key };
    return { anchor: cell, focus: cell };
  });
  const [editing, setEditing] = React.useState(false);
  const [draft, setDraft] = React.useState("");
  const [error, setError] = React.useState("");
  const [message, setMessage] = React.useState("");
  const [history, setHistory] = React.useState<{
    undo: Transaction<T>[];
    redo: Transaction<T>[];
  }>({ undo: [], redo: [] });
  const [scrollTop, setScrollTop] = React.useState(0);
  const visible = columns.filter((column) => !hidden.includes(column.key));
  const rows = React.useMemo(() => {
    const q = query.trim().toLocaleLowerCase();
    const result = data.filter(
      (row) =>
        (!q ||
          columns.some((c) =>
            String(row[c.key] ?? "")
              .toLocaleLowerCase()
              .includes(q)
          )) &&
        columns.every(
          (c) =>
            filters[c.key] === undefined ||
            String(row[c.key] ?? "") === filters[c.key]
        )
    );
    if (sort) {
      result.sort((a, b) => {
        const x = a[sort.key as keyof T],
          y = b[sort.key as keyof T];
        const n =
          typeof x === "number" && typeof y === "number"
            ? x - y
            : String(x ?? "").localeCompare(String(y ?? ""), undefined, {
                numeric: true,
              });
        return sort.descending ? -n : n;
      });
    }
    return result;
  }, [data, columns, query, filters, sort]);
  const activeRow = range
    ? rows.findIndex((row) => getRowId(row) === range.focus.rowId)
    : -1;
  const activeColumn = range
    ? visible.findIndex((column) => column.key === range.focus.key)
    : -1;
  const anchorRow = range
    ? rows.findIndex((row) => getRowId(row) === range.anchor.rowId)
    : -1;
  const anchorColumn = range
    ? visible.findIndex((column) => column.key === range.anchor.key)
    : -1;
  const bounds =
    activeRow < 0 || activeColumn < 0 || anchorRow < 0 || anchorColumn < 0
      ? null
      : {
          top: Math.min(activeRow, anchorRow),
          bottom: Math.max(activeRow, anchorRow),
          start: Math.min(activeColumn, anchorColumn),
          end: Math.max(activeColumn, anchorColumn),
        };
  const rowHeight = compact ? 36 : 44;
  const first = Math.max(0, Math.floor(scrollTop / rowHeight) - 4);
  const last = Math.min(
    rows.length,
    Math.ceil((scrollTop + height) / rowHeight) + 4
  );
  const template = `48px ${visible.map((c) => `${widths[c.key] ?? c.width ?? 160}px`).join(" ")}`;
  const freeze =
    viewportWidth === 0 ||
    viewportWidth >=
      48 + (widths[visible[0]?.key] ?? visible[0]?.width ?? 160) + 96;
  const totalWidth =
    48 + visible.reduce((n, c) => n + (widths[c.key] ?? c.width ?? 160), 0);
  const rowStyle = {
    gridTemplateColumns: template,
    width: totalWidth,
    minWidth: "100%",
  };
  function select(r: number, c: number, extend = false) {
    r = Math.max(0, Math.min(rows.length - 1, r));
    c = Math.max(0, Math.min(visible.length - 1, c));
    const row = rows[r],
      column = visible[c];
    if (!row || !column) {
      return;
    }
    const cell = { rowId: getRowId(row), key: column.key };
    setRange((old) => ({
      anchor: extend && old && bounds ? old.anchor : cell,
      focus: cell,
    }));
    setEditing(false);
    setError("");
    grid.current?.focus({ preventScroll: true });
    if (body.current) {
      const el = body.current;
      if (r * rowHeight < el.scrollTop) {
        el.scrollTop = r * rowHeight;
      } else if ((r + 1) * rowHeight > el.scrollTop + el.clientHeight) {
        el.scrollTop = (r + 1) * rowHeight - el.clientHeight;
      }
      const left =
        48 +
        visible
          .slice(0, c)
          .reduce((n, v) => n + (widths[v.key] ?? v.width ?? 160), 0);
      const width = widths[column.key] ?? column.width ?? 160;
      const offset = Math.abs(el.scrollLeft);
      const frozenWidth =
        48 +
        (freeze ? (widths[visible[0]?.key] ?? visible[0]?.width ?? 160) : 0);
      const sign = direction === "rtl" ? -1 : 1;
      if (c > 0 || !freeze) {
        if (left < offset + frozenWidth) {
          el.scrollLeft = sign * Math.max(0, left - frozenWidth);
        } else if (left + width > offset + el.clientWidth) {
          el.scrollLeft = sign * (left + width - el.clientWidth);
        }
      }
    }
  }
  function commit(edits: GridEdit<T>[]) {
    if (!onDataChange || !edits.length) {
      return;
    }
    onDataChange(applyGridEdits(data, getRowId, edits));
    setHistory((h) => ({
      undo: [...h.undo.slice(-99), { type: "edit", edits }],
      redo: [],
    }));
    setError("");
    setMessage(
      `${edits.length} ${edits.length === 1 ? "cell updated" : "cells updated"}.`
    );
  }
  function editValue(
    row: T,
    column: DataGridColumn<T>,
    value: string
  ): GridEdit<T> {
    if (!onDataChange || column.editable === false) {
      throw new Error(`${column.header} is read-only.`);
    }
    const parsed = parseGridValue(value, column);
    const problem = column.validate?.(parsed, row);
    if (problem) {
      throw new Error(`${column.header}: ${problem}`);
    }
    return {
      rowId: getRowId(row),
      key: column.key,
      before: row[column.key],
      after: parsed as T[keyof T],
    };
  }
  function finish(move = 0) {
    const row = rows[activeRow],
      column = visible[activeColumn];
    if (!row || !column) {
      return;
    }
    try {
      const edit = editValue(row, column, draft);
      if (!Object.is(edit.before, edit.after)) {
        commit([edit]);
      }
      setEditing(false);
      if (Math.abs(move) === 2) {
        const next = Math.max(
          0,
          Math.min(
            rows.length * visible.length - 1,
            activeRow * visible.length + activeColumn + Math.sign(move)
          )
        );
        select(Math.floor(next / visible.length), next % visible.length);
      } else if (move) {
        select(activeRow + move, activeColumn);
      } else {
        grid.current?.focus();
      }
    } catch (error) {
      setError(error instanceof Error ? error.message : "Invalid value.");
    }
  }
  function travel(undo: boolean) {
    const stack = undo ? history.undo : history.redo;
    const transaction = stack.at(-1);
    if (!transaction || !onDataChange) {
      return;
    }
    const current = new Map(data.map((row) => [getRowId(row), row]));
    const stale =
      transaction.type === "edit"
        ? transaction.edits.some(
            (edit) =>
              !current.has(edit.rowId) ||
              !Object.is(
                current.get(edit.rowId)?.[edit.key],
                undo ? edit.after : edit.before
              )
          )
        : transaction.rows.some(({ row }) =>
            undo
              ? current.has(getRowId(row))
              : current.get(getRowId(row)) !== row
          );
    if (stale) {
      setHistory({ undo: [], redo: [] });
      setMessage(
        "The data changed outside this grid. Change history has been cleared."
      );
      return;
    }
    if (transaction.type === "edit") {
      onDataChange(applyGridEdits(data, getRowId, transaction.edits, undo));
    } else if (undo) {
      const next = [...data];
      for (const { row, index } of transaction.rows) {
        next.splice(Math.min(index, next.length), 0, row);
      }
      onDataChange(next);
    } else {
      const removed = new Set(transaction.rows.map(({ row }) => getRowId(row)));
      onDataChange(data.filter((row) => !removed.has(getRowId(row))));
    }
    setHistory(
      undo
        ? { undo: stack.slice(0, -1), redo: [...history.redo, transaction] }
        : { undo: [...history.undo, transaction], redo: stack.slice(0, -1) }
    );
    setEditing(false);
    setMessage(undo ? "Change undone." : "Change redone.");
    setError("");
  }
  const selectedRows = rows.filter((row) => selected.includes(getRowId(row)));
  async function copyRows() {
    try {
      await navigator.clipboard.writeText(
        serializeGridRows(
          selectedRows.map((row) => visible.map((column) => row[column.key])),
          "\t"
        )
      );
      setMessage(
        `${selectedRows.length} ${selectedRows.length === 1 ? "row" : "rows"} copied.`
      );
      setError("");
    } catch {
      setError("Could not copy rows. Allow clipboard access and try again.");
    }
  }
  function deleteRows() {
    if (!onDataChange || !selectedRows.length) {
      return;
    }
    const removed = new Set(selectedRows.map(getRowId));
    const deleted = data.flatMap((row, index) =>
      removed.has(getRowId(row)) ? [{ row, index }] : []
    );
    onDataChange(data.filter((row) => !removed.has(getRowId(row))));
    setHistory((h) => ({
      undo: [...h.undo.slice(-99), { type: "delete", rows: deleted }],
      redo: [],
    }));
    setSelected((s) => s.filter((id) => !removed.has(id)));
    setRange((old) =>
      old && (removed.has(old.anchor.rowId) || removed.has(old.focus.rowId))
        ? null
        : old
    );
    setEditing(false);
    setMessage(
      `${deleted.length} ${deleted.length === 1 ? "row" : "rows"} deleted.`
    );
  }

  function exportCsv() {
    const exportRows = selected.length
      ? rows.filter((r) => selected.includes(getRowId(r)))
      : rows;
    const csv = serializeGridRows([
      visible.map((c) => c.header),
      ...exportRows.map((r) => visible.map((c) => r[c.key])),
    ]);
    const url = URL.createObjectURL(
      new Blob(["\uFEFF", csv], { type: "text/csv;charset=utf-8" })
    );
    const link = document.createElement("a");
    link.href = url;
    link.download = exportFileName;
    link.click();
    setTimeout(() => URL.revokeObjectURL(url), 1000);
    setMessage(
      `${exportRows.length} ${exportRows.length === 1 ? "row" : "rows"} exported.`
    );
  }
  const selectedValues = bounds
    ? rows
        .slice(bounds.top, bounds.bottom + 1)
        .flatMap((r) =>
          visible.slice(bounds.start, bounds.end + 1).map((c) => r[c.key])
        )
    : [];
  const numbers = (selectedValues as unknown[]).filter(
    (v): v is number => typeof v === "number" && Number.isFinite(v)
  );
  const sum = numbers.reduce((a, b) => a + b, 0);
  const surface = "bg-[var(--surface-card,var(--background))]";
  const secondary = "bg-[var(--surface-sunken,var(--muted))]";
  const border = "border-[var(--command-border,var(--border))]";
  return (
    <div
      dir={direction}
      data-slot="data-grid"
      className={cn(
        "w-full min-w-0 overflow-hidden rounded-[16px] border text-sm text-foreground",
        surface,
        border,
        className
      )}
    >
      <div
        className={cn(
          "flex flex-wrap items-center gap-2 border-b px-2 py-[7px]",
          border
        )}
      >
        <div className="me-auto flex min-h-10 flex-col justify-center">
          <span className="text-muted-foreground">Total results</span>
          <span className="font-medium">
            {rows.length.toLocaleString()} {rows.length === 1 ? "row" : "rows"}
          </span>
        </div>
        {selectedRows.length > 0 && (
          <>
            <span>{selectedRows.length} selected</span>
            <Button
              variant="outline"
              size="sm"
              disabled={!onDataChange}
              onClick={deleteRows}
            >
              Delete
            </Button>
            <Button variant="outline" size="sm" onClick={copyRows}>
              Copy rows
            </Button>
            <Button variant="ghost" size="sm" onClick={() => setSelected([])}>
              Clear
            </Button>
          </>
        )}
        <Button
          variant="outline"
          size="icon-sm"
          aria-label="Undo edit"
          disabled={!history.undo.length || !onDataChange}
          onClick={() => travel(true)}
        >
          <RiArrowGoBackLine />
        </Button>
        <Button
          variant="outline"
          size="icon-sm"
          aria-label="Redo edit"
          disabled={!history.redo.length || !onDataChange}
          onClick={() => travel(false)}
        >
          <RiArrowGoForwardLine />
        </Button>
        <Popover
          open={menu === "filters"}
          onOpenChange={(open) => setMenu(open ? "filters" : null)}
        >
          <PopoverTrigger render={<Button variant="outline" size="sm" />}>
            Filters
          </PopoverTrigger>
          <PopoverContent align="end" className={popupClassName}>
            <div className={cn("flex flex-col gap-2", surface, border)}>
              {columns
                .filter((c) => c.filterable || c.type === "select")
                .map((c) => (
                  <label key={c.key} className="flex flex-col gap-1">
                    {c.header}
                    <select
                      className={cn(
                        "rounded-[4px] border p-1",
                        surface,
                        border
                      )}
                      value={filters[c.key] ?? ""}
                      onChange={(e) =>
                        setFilters((f) => {
                          const next = { ...f };
                          if (e.target.value === "") {
                            delete next[c.key];
                          } else {
                            next[c.key] = e.target.value;
                          }
                          return next;
                        })
                      }
                    >
                      <option value="">All</option>
                      {(
                        c.options ?? [
                          ...new Set(data.map((r) => String(r[c.key] ?? ""))),
                        ]
                      )
                        .filter(Boolean)
                        .map((v) => (
                          <option key={v} value={v}>
                            {v}
                          </option>
                        ))}
                    </select>
                  </label>
                ))}
              <Button variant="ghost" size="sm" onClick={() => setFilters({})}>
                Clear filters
              </Button>
            </div>
          </PopoverContent>
        </Popover>
        <Popover
          open={menu === "columns"}
          onOpenChange={(open) => setMenu(open ? "columns" : null)}
        >
          <PopoverTrigger render={<Button variant="outline" size="sm" />}>
            Columns
          </PopoverTrigger>
          <PopoverContent align="end" className={popupClassName}>
            <div className={cn("flex flex-col gap-2", surface, border)}>
              {columns.map((c) => (
                <label key={c.key} className="flex items-center gap-2">
                  <Checkbox
                    checked={!hidden.includes(c.key)}
                    disabled={!hidden.includes(c.key) && visible.length === 1}
                    onCheckedChange={(checked) =>
                      setHidden((h) =>
                        checked ? h.filter((k) => k !== c.key) : [...h, c.key]
                      )
                    }
                  />
                  {c.header}
                </label>
              ))}
            </div>
          </PopoverContent>
        </Popover>
        <Button
          variant="outline"
          size="sm"
          onClick={() => {
            setCompact(!compact);
            setScrollTop(0);
            if (body.current) {
              body.current.scrollTop = 0;
            }
          }}
        >
          {compact ? "Comfortable" : "Compact"}
        </Button>
        <Button
          variant="outline"
          size="icon-sm"
          aria-label="Export"
          disabled={!rows.length}
          onClick={exportCsv}
        >
          <RiDownloadLine />
        </Button>
        <Input
          aria-label={`Search ${label}`}
          placeholder="Search"
          value={query}
          onChange={(e) => setQuery(e.target.value)}
          className="w-40 rounded-full sm:w-56"
        />
      </div>
      <div
        ref={grid}
        role="grid"
        aria-label={label}
        aria-describedby={`${id}-instructions`}
        aria-rowcount={rows.length + 1}
        aria-colcount={visible.length + 1}
        aria-multiselectable="true"
        aria-readonly={!onDataChange}
        aria-activedescendant={
          activeRow >= first && activeRow < last && activeColumn >= 0
            ? `${id}-${activeRow}-${activeColumn}`
            : undefined
        }
        tabIndex={0}
        className="outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring data-[pointer]:focus-visible:ring-0"
        onFocus={(e) => {
          if (e.target === e.currentTarget && !range) {
            select(0, 0);
          }
        }}
        onBlur={(e) => {
          if (!e.currentTarget.contains(e.relatedTarget)) {
            delete e.currentTarget.dataset.pointer;
          }
        }}
        onKeyDown={(e) => {
          delete e.currentTarget.dataset.pointer;
          if (editing) {
            return;
          }
          const moves: Record<string, [number, number]> = {
            ArrowUp: [-1, 0],
            ArrowDown: [1, 0],
            ArrowLeft: [0, direction === "rtl" ? 1 : -1],
            ArrowRight: [0, direction === "rtl" ? -1 : 1],
          };
          if (moves[e.key]) {
            e.preventDefault();
            const [dr, dc] = moves[e.key];
            select(
              Math.max(0, activeRow) + dr,
              Math.max(0, activeColumn) + dc,
              e.shiftKey
            );
          } else if (
            (e.key === "Enter" || e.key === "F2") &&
            onDataChange &&
            activeRow >= 0 &&
            visible[activeColumn]?.editable !== false
          ) {
            e.preventDefault();
            setDraft(String(rows[activeRow][visible[activeColumn].key] ?? ""));
            setEditing(true);
          } else if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "z") {
            e.preventDefault();
            travel(!e.shiftKey);
          } else if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "y") {
            e.preventDefault();
            travel(false);
          } else if (
            (e.metaKey || e.ctrlKey) &&
            e.key.toLowerCase() === "a" &&
            rows.length &&
            visible.length
          ) {
            e.preventDefault();
            setRange({
              anchor: { rowId: getRowId(rows[0]), key: visible[0].key },
              focus: {
                rowId: getRowId(rows.at(-1) as T),
                key: (visible.at(-1) as DataGridColumn<T>).key,
              },
            });
          } else if (e.key === "Escape") {
            setRange(null);
          } else if (["Home", "End", "PageUp", "PageDown"].includes(e.key)) {
            e.preventDefault();
            const page = Math.max(1, Math.floor(height / rowHeight));
            const row = Math.max(0, activeRow);
            const column = Math.max(0, activeColumn);
            const command = e.metaKey || e.ctrlKey;
            if (e.key === "Home") {
              select(command ? 0 : row, 0, e.shiftKey);
            } else if (e.key === "End") {
              select(
                command ? rows.length - 1 : row,
                visible.length - 1,
                e.shiftKey
              );
            } else {
              select(
                row + (e.key === "PageDown" ? page : -page),
                column,
                e.shiftKey
              );
            }
          }
        }}
        onCopy={(e) => {
          if (!bounds || editing) {
            return;
          }
          e.preventDefault();
          e.clipboardData.setData(
            "text/plain",
            serializeGridRows(
              rows
                .slice(bounds.top, bounds.bottom + 1)
                .map((r) =>
                  visible
                    .slice(bounds.start, bounds.end + 1)
                    .map((c) => r[c.key])
                ),
              "\t"
            )
          );
          setMessage(
            `${(bounds.bottom - bounds.top + 1) * (bounds.end - bounds.start + 1)} cells copied.`
          );
        }}
        onPaste={(e) => {
          if (!bounds || editing || !onDataChange) {
            return;
          }
          e.preventDefault();
          try {
            const edits: GridEdit<T>[] = [];
            parseGridClipboard(e.clipboardData.getData("text/plain")).forEach(
              (line, r) =>
                line.forEach((v, c) => {
                  const row = rows[bounds.top + r],
                    column = visible[bounds.start + c];
                  if (!row || !column) {
                    throw new Error("The pasted cells extend beyond the grid.");
                  }
                  edits.push(editValue(row, column, v));
                })
            );
            commit(edits);
          } catch (error) {
            setError(
              `Paste cancelled. ${error instanceof Error ? error.message : "Invalid value."}`
            );
          }
        }}
      >
        <p id={`${id}-instructions`} className="sr-only">
          Use arrow keys to navigate, Shift and arrows to select cells, and
          Enter or F2 to edit. Copy and paste cells with your keyboard. Column
          headings sort; column separators resize with arrow keys.
        </p>
        <div
          // A div-based ARIA grid: the header and body scroll separately, so
          // they cannot be thead/tbody.
          // oxlint-disable-next-line jsx-a11y/prefer-tag-over-role
          role="rowgroup"
          className={cn("overflow-hidden border-b", border, secondary)}
        >
          <div role="row" className="grid h-10" style={rowStyle}>
            <div
              role="columnheader"
              className={cn(
                "sticky start-0 z-20 flex items-center justify-center border-e",
                border,
                secondary
              )}
            >
              <Checkbox
                aria-label="Select all filtered rows"
                checked={
                  !!rows.length &&
                  rows.every((r) => selected.includes(getRowId(r)))
                }
                indeterminate={
                  rows.some((r) => selected.includes(getRowId(r))) &&
                  !rows.every((r) => selected.includes(getRowId(r)))
                }
                onCheckedChange={(checked) =>
                  setSelected((s) =>
                    checked
                      ? [...new Set([...s, ...rows.map(getRowId)])]
                      : s.filter((id) => !rows.some((r) => getRowId(r) === id))
                  )
                }
              />
            </div>
            {visible.map((c, col) => (
              <div
                role="columnheader"
                aria-sort={
                  sort?.key === c.key
                    ? sort.descending
                      ? "descending"
                      : "ascending"
                    : "none"
                }
                key={c.key}
                className={cn(
                  "group relative flex border-e",
                  border,
                  col === 0 && freeze && "sticky start-12 z-10",
                  secondary
                )}
              >
                <button
                  type="button"
                  className="flex h-full min-w-0 flex-1 items-center gap-2 px-3 text-start font-medium text-muted-foreground hover:text-foreground"
                  onClick={() =>
                    setSort((s) =>
                      s?.key === c.key
                        ? s.descending
                          ? null
                          : { key: c.key, descending: true }
                        : { key: c.key, descending: false }
                    )
                  }
                >
                  <span className="truncate">{c.header}</span>
                  {sort?.key === c.key &&
                    (sort.descending ? (
                      <RiArrowDownLine className="size-3.5" />
                    ) : (
                      <RiArrowUpLine className="size-3.5" />
                    ))}
                </button>
                <div
                  role="separator"
                  aria-label={`Resize ${c.header}`}
                  aria-orientation="vertical"
                  aria-valuemin={96}
                  aria-valuemax={480}
                  aria-valuenow={widths[c.key] ?? c.width ?? 160}
                  tabIndex={0}
                  className="absolute inset-y-0 -end-1 z-10 w-2 cursor-col-resize hover:bg-primary/20 focus-visible:bg-primary/20"
                  onDoubleClick={() =>
                    setWidths((w) => ({ ...w, [c.key]: c.width ?? 160 }))
                  }
                  onKeyDown={(e) => {
                    if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
                      e.preventDefault();
                      e.stopPropagation();
                      setWidths((w) => ({
                        ...w,
                        [c.key]: Math.max(
                          96,
                          Math.min(
                            480,
                            (w[c.key] ?? c.width ?? 160) +
                              (e.key ===
                              (direction === "rtl" ? "ArrowLeft" : "ArrowRight")
                                ? 16
                                : -16)
                          )
                        ),
                      }));
                    }
                  }}
                  onPointerDown={(e) => {
                    const start = e.clientX,
                      width = widths[c.key] ?? c.width ?? 160,
                      el = e.currentTarget;
                    el.setPointerCapture(e.pointerId);
                    const move = (ev: PointerEvent) =>
                      setWidths((w) => ({
                        ...w,
                        [c.key]: Math.max(
                          96,
                          Math.min(
                            480,
                            width +
                              (ev.clientX - start) *
                                (direction === "rtl" ? -1 : 1)
                          )
                        ),
                      }));
                    const stop = () => {
                      el.removeEventListener("pointermove", move);
                      el.removeEventListener("lostpointercapture", stop);
                    };
                    el.addEventListener("pointermove", move);
                    el.addEventListener("lostpointercapture", stop);
                  }}
                />
              </div>
            ))}
          </div>
        </div>
        <motion.div
          layoutScroll
          ref={body}
          // oxlint-disable-next-line jsx-a11y/prefer-tag-over-role
          role="rowgroup"
          style={{ height }}
          className="overflow-auto overscroll-contain [scrollbar-width:thin]"
          onScroll={(e) => {
            setScrollTop(e.currentTarget.scrollTop);
            const header = e.currentTarget.previousElementSibling;
            if (header) {
              header.scrollLeft = e.currentTarget.scrollLeft;
            }
            if (summaryRow.current) {
              summaryRow.current.scrollLeft = e.currentTarget.scrollLeft;
            }
          }}
        >
          <div
            className="relative"
            style={{
              height: rows.length ? rows.length * rowHeight : height,
              width: totalWidth,
              minWidth: "100%",
            }}
          >
            {rows.slice(first, last).map((row, index) => {
              const r = first + index,
                rowId = getRowId(row);
              return (
                <div
                  role="row"
                  aria-rowindex={r + 2}
                  aria-selected={selected.includes(rowId)}
                  key={rowId}
                  className={cn(
                    "group/row absolute inset-x-0 grid border-b",
                    border
                  )}
                  style={{ ...rowStyle, top: r * rowHeight, height: rowHeight }}
                >
                  <div
                    role="gridcell"
                    className={cn(
                      "sticky start-0 z-20 flex items-center justify-center border-e text-xs text-muted-foreground",
                      border,
                      surface
                    )}
                  >
                    <span
                      className={cn(
                        "group-hover/row:hidden group-focus-within/row:hidden",
                        selected.includes(rowId) && "hidden"
                      )}
                    >
                      {r + 1}
                    </span>
                    <Checkbox
                      aria-label={`Select row ${r + 1}`}
                      checked={selected.includes(rowId)}
                      onCheckedChange={(checked) =>
                        setSelected((s) =>
                          checked ? [...s, rowId] : s.filter((x) => x !== rowId)
                        )
                      }
                      className={cn(
                        "hidden group-hover/row:flex group-focus-within/row:flex",
                        selected.includes(rowId) && "flex"
                      )}
                    />
                  </div>
                  {visible.map((c, col) => {
                    const active = activeRow === r && activeColumn === col,
                      inRange =
                        !!bounds &&
                        r >= bounds.top &&
                        r <= bounds.bottom &&
                        col >= bounds.start &&
                        col <= bounds.end;
                    return (
                      <div
                        role="gridcell"
                        id={`${id}-${r}-${col}`}
                        aria-colindex={col + 2}
                        aria-selected={inRange}
                        key={c.key}
                        className={cn(
                          "relative flex min-w-0 cursor-cell items-center border-e px-3 transition-colors duration-150 ease-out motion-reduce:transition-none",
                          border,
                          surface,
                          col === 0 && freeze && "sticky start-12 z-10",
                          (inRange || selected.includes(rowId)) && secondary,
                          (c.align === "end" ||
                            (!c.align && c.type === "number")) &&
                            "justify-end text-end tabular-nums"
                        )}
                        onPointerDown={(e) => {
                          if (editing || e.button !== 0) {
                            return;
                          }
                          if (e.pointerType === "mouse") {
                            e.preventDefault();
                            // Script focus after a prevented mousedown matches
                            // :focus-visible; hide the grid ring until a key press.
                            if (grid.current) {
                              grid.current.dataset.pointer = "";
                            }
                          }
                          select(r, col, e.shiftKey);
                        }}
                        onPointerEnter={(e) => {
                          if (
                            e.pointerType === "mouse" &&
                            e.buttons === 1 &&
                            !editing
                          ) {
                            select(r, col, true);
                          }
                        }}
                        onDoubleClick={() => {
                          if (onDataChange && c.editable !== false) {
                            setDraft(String(row[c.key] ?? ""));
                            setEditing(true);
                          }
                        }}
                      >
                        {active && (
                          <motion.span
                            aria-hidden
                            layoutId={`${id}-highlight`}
                            initial={false}
                            transition={{
                              layout: reduced
                                ? { duration: 0 }
                                : {
                                    type: "spring",
                                    stiffness: 480,
                                    damping: 38,
                                    mass: 0.7,
                                  },
                            }}
                            className="pointer-events-none absolute inset-0 ring-2 ring-inset ring-[var(--foreground-faint,var(--ring))]"
                          />
                        )}
                        {active && editing ? (
                          c.type === "select" ? (
                            <select
                              autoFocus
                              ref={(element) => {
                                if (element) {
                                  try {
                                    element.showPicker?.();
                                  } catch {
                                    /* Picker may require user activation. */
                                  }
                                }
                              }}
                              aria-label={`Edit ${c.header}`}
                              value={draft}
                              className={cn(
                                "absolute inset-0 size-full px-2",
                                surface
                              )}
                              onChange={(e) => setDraft(e.target.value)}
                              onBlur={() => finish()}
                              onKeyDown={(e) => {
                                e.stopPropagation();
                                if (e.key === "Escape") {
                                  setEditing(false);
                                  grid.current?.focus();
                                } else if (e.key === "Tab") {
                                  e.preventDefault();
                                  finish(e.shiftKey ? -2 : 2);
                                } else if (e.key === "Enter") {
                                  e.preventDefault();
                                  finish(e.shiftKey ? -1 : 1);
                                }
                              }}
                            >
                              {(c.options ?? []).map((option) => (
                                <option key={option}>{option}</option>
                              ))}
                            </select>
                          ) : (
                            <input
                              autoFocus
                              aria-label={`Edit ${c.header}`}
                              type={c.type === "number" ? "number" : "text"}
                              value={draft}
                              className={cn(
                                "absolute inset-0 size-full px-2 outline-none",
                                surface
                              )}
                              onChange={(e) => setDraft(e.target.value)}
                              onBlur={() => finish()}
                              onKeyDown={(e) => {
                                e.stopPropagation();
                                if (e.key === "Escape") {
                                  setEditing(false);
                                  grid.current?.focus();
                                } else if (e.key === "Tab") {
                                  e.preventDefault();
                                  finish(e.shiftKey ? -2 : 2);
                                } else if (e.key === "Enter") {
                                  e.preventDefault();
                                  finish(e.shiftKey ? -1 : 1);
                                }
                              }}
                            />
                          )
                        ) : (
                          <span
                            className="truncate"
                            title={String(row[c.key] ?? "")}
                          >
                            {c.format
                              ? c.format(row[c.key])
                              : String(row[c.key] ?? "")}
                          </span>
                        )}
                      </div>
                    );
                  })}
                </div>
              );
            })}
            {!rows.length && (
              <div className="flex h-full items-center p-6 text-muted-foreground">
                {emptyState}
              </div>
            )}
          </div>
        </motion.div>
      </div>
      <div ref={summaryRow} className={cn("overflow-hidden border-t", border)}>
        <div className="grid h-11" style={rowStyle}>
          <div className={cn("border-e", border)} />
          {visible.map((c, col) => {
            const values = (rows.map((r) => r[c.key]) as unknown[]).filter(
              (v): v is number => typeof v === "number" && Number.isFinite(v)
            );
            const total = values.reduce((a, b) => a + b, 0);
            const value =
              c.summary === "count"
                ? rows.length
                : c.summary === "average"
                  ? values.length
                    ? total / values.length
                    : null
                  : total;
            return (
              <div
                key={c.key}
                className={cn(
                  "flex items-center justify-end gap-2 border-e px-3 text-muted-foreground tabular-nums",
                  border,
                  surface,
                  col === 0 && freeze && "sticky start-12 z-10"
                )}
              >
                {c.summary && (
                  <>
                    <span>
                      {c.summary === "average"
                        ? "Avg"
                        : c.summary === "sum"
                          ? "Sum"
                          : "Count"}
                    </span>
                    <span className="font-medium text-foreground">
                      {value === null
                        ? "—"
                        : c.formatSummary
                          ? c.formatSummary(value)
                          : value.toLocaleString(undefined, {
                              maximumFractionDigits: 1,
                            })}
                    </span>
                  </>
                )}
              </div>
            );
          })}
        </div>
      </div>
      <div
        className={cn(
          "flex min-h-10 flex-wrap items-center gap-4 border-t px-4 py-2 text-muted-foreground",
          border
        )}
      >
        <span className="font-medium text-foreground">
          {bounds
            ? `${columnLetter(bounds.start)}${bounds.top + 1}${bounds.top !== bounds.bottom || bounds.start !== bounds.end ? `:${columnLetter(bounds.end)}${bounds.bottom + 1}` : ""}`
            : "—"}
        </span>
        <span>
          Count{" "}
          {
            selectedValues.filter(
              (v) => v !== null && v !== undefined && v !== ""
            ).length
          }
        </span>
        {numbers.length > 0 && (
          <>
            <span>Sum {sum.toLocaleString()}</span>
            <span>
              Avg{" "}
              {(sum / numbers.length).toLocaleString(undefined, {
                maximumFractionDigits: 1,
              })}
            </span>
          </>
        )}
        {!onDataChange && <span>Read-only</span>}
        <span className="sr-only" role="status">
          {message}
        </span>
      </div>
      {error && (
        <p
          role="alert"
          className={cn("border-t px-4 py-2 text-destructive", border)}
        >
          {error}
        </p>
      )}
    </div>
  );
}