# Spreadsheet

> Spreadsheet data grid with AI enrichment — select cells, hit Enrich and each fills in after a Generating… beat; editable cells, drag/shift selection, arrow keys, resizable columns.

A shadcn/ui block from the [@uicapsule](https://uicapsule.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/uicapsule/spreadsheet).

- Page, with a live preview: https://shadcn.exchange/r/uicapsule/spreadsheet
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/uicapsule/spreadsheet-light-4f0166ac32dbe3f6.png
- Registry item: https://uicapsule.com/r/spreadsheet.json
- Categories: data, minimal
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://uicapsule.com/r/spreadsheet.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `@ai-sdk/react`, `@base-ui/react`, `@loremllm/transport`, `@tanstack/react-table`, `@tanstack/react-virtual`, `cn`, `lucide-react`, `zod`, `zustand`

## Files

### `/components/editable-cell.tsx`

```tsx
"use client";

import type { ChangeEvent, KeyboardEvent } from "react";
import { Input } from "./ui";

import type { SpreadsheetRow } from "../lib/spreadsheet-store";
import { useSpreadsheetStore } from "../lib/spreadsheet-store";

interface EditableCellProps {
  row: { original: SpreadsheetRow };
  column: { id: string };
}

export const EditableCell = ({ row, column }: EditableCellProps) => {
  const editingCell = useSpreadsheetStore((state) => state.editingCell);
  const setEditingCell = useSpreadsheetStore((state) => state.setEditingCell);
  const updateData = useSpreadsheetStore((state) => state.updateData);
  // Cells are cleared to `undefined` by Delete/Backspace; coerce so <Input> stays controlled.
  const value = row.original[column.id] ?? "";
  const isEditing = editingCell?.rowId === row.original.id && editingCell?.columnId === column.id;

  const onChange = (e: ChangeEvent<HTMLInputElement>) => {
    updateData(row.original.id, column.id, e.target.value);
  };

  const onBlur = () => {
    setEditingCell(null);
  };

  const onKeyDown = (e: KeyboardEvent) => {
    if (e.key === "Enter" || e.key === "Escape") {
      setEditingCell(null);
    }
    e.stopPropagation();
  };

  if (isEditing) {
    return (
      <Input
        value={value}
        onChange={onChange}
        onBlur={onBlur}
        onKeyDown={onKeyDown}
        className="h-8 w-full rounded-none border-0 bg-transparent p-1 text-sm focus-visible:ring-blue-600/20 dark:focus-visible:ring-blue-400/30"
        autoFocus
      />
    );
  }

  return (
    <div className="h-8 w-full p-1">
      <span className="text-(--foreground) block truncate text-sm" title={value}>
        {value}
      </span>
    </div>
  );
};
```

### `/components/resize-handle.tsx`

```tsx
"use client";

import { useRef, useState } from "react";
import type { MouseEvent as ReactMouseEvent, RefObject, Ref } from "react";
import { cn } from "cn";

import { MIN_COLUMN_WIDTH } from "../lib/spreadsheet-utils";

/** Half the handle's hit width, so the drag line sits centred on it. */
const DRAG_LINE_OFFSET = 2;

interface ResizeHandleProps {
  columnId: string;
  width: number;
  handleColumnResize: (columnId: string, width: number) => void;
  tableContainerRef: RefObject<HTMLDivElement | null>;
  dragLineRef: RefObject<HTMLDivElement | null>;
  setDragLineVisible: (visible: boolean) => void;
  className?: string;
  ref?: Ref<HTMLDivElement>;
}

export const ResizeHandle = ({
  columnId,
  width,
  handleColumnResize,
  tableContainerRef,
  dragLineRef,
  setDragLineVisible,
  className,
  ref,
}: ResizeHandleProps) => {
  const [isResizing, setIsResizing] = useState(false);
  const startXRef = useRef(0);
  const startWidthRef = useRef(0);
  const columnStartXRef = useRef(0);
  const currentMouseXRef = useRef(0);

  const handleMouseDown = (e: ReactMouseEvent) => {
    e.preventDefault();
    e.stopPropagation();
    setIsResizing(true);
    setDragLineVisible(true);
    startXRef.current = e.clientX;
    // Seed with the press position so a click with no drag commits the current width.
    currentMouseXRef.current = e.clientX;
    startWidthRef.current = width;

    const headerElement = e.currentTarget.closest("[data-column-id]");
    if (headerElement instanceof HTMLElement) {
      const rect = headerElement.getBoundingClientRect();
      const tableRect = tableContainerRef.current?.getBoundingClientRect();
      if (tableRect) {
        columnStartXRef.current = rect.left - tableRect.left;
        const initialDragX = columnStartXRef.current + startWidthRef.current - DRAG_LINE_OFFSET;
        if (dragLineRef.current) {
          dragLineRef.current.style.left = `${initialDragX}px`;
        }
      }
    }

    const widthForClientX = (clientX: number) =>
      Math.max(MIN_COLUMN_WIDTH, startWidthRef.current + (clientX - startXRef.current));

    const handleMouseMove = (event: MouseEvent) => {
      currentMouseXRef.current = event.clientX;
      const dragX = columnStartXRef.current + widthForClientX(event.clientX) - DRAG_LINE_OFFSET;

      if (dragLineRef.current) {
        dragLineRef.current.style.left = `${dragX}px`;
      }
    };

    const handleMouseUp = () => {
      setIsResizing(false);
      setDragLineVisible(false);
      handleColumnResize(columnId, widthForClientX(currentMouseXRef.current));
      document.removeEventListener("mousemove", handleMouseMove);
      document.removeEventListener("mouseup", handleMouseUp);
    };

    document.addEventListener("mousemove", handleMouseMove);
    document.addEventListener("mouseup", handleMouseUp);
  };

  return (
    <div
      ref={ref}
      role="presentation"
      className={cn(
        "hover:bg-(--primary)/50 dark:hover:bg-(--primary)/60 absolute top-0 right-0 h-full w-1 cursor-col-resize transition-colors",
        isResizing && "bg-(--primary) dark:bg-(--primary)/80",
        className,
      )}
      onMouseDown={handleMouseDown}
    />
  );
};
```

### `/components/spreadsheet.tsx`

```tsx
"use client";

import { useCallback, useEffect, useMemo, useRef } from "react";
import type { HTMLAttributes, ReactNode, Ref } from "react";
import { cn } from "cn";
import { flexRender, useTable } from "@tanstack/react-table";
import { useVirtualizer } from "@tanstack/react-virtual";

import type { SpreadsheetRow } from "../lib/spreadsheet-store";
import type { ColumnInfo, NavigationMap, SpreadsheetFeatures } from "../lib/spreadsheet-utils";
import type { ColumnDef } from "@tanstack/react-table";
import { useSpreadsheetStore } from "../lib/spreadsheet-store";
import {
  createNavigationMap,
  getColumnSizeVars,
  getRowCells,
  MIN_COLUMN_WIDTH,
  spreadsheetFeatures,
} from "../lib/spreadsheet-utils";
import { useSpreadsheetHandlers } from "../lib/use-spreadsheet-handlers";
import { TableBody } from "./table-body";
import { ResizeHandle } from "./resize-handle";

export interface SpreadsheetProps extends HTMLAttributes<HTMLDivElement> {
  columns: ColumnDef<SpreadsheetFeatures, SpreadsheetRow>[];
  showRowNumbers?: boolean;
  renderRowNumber?: (rowIndex: number) => ReactNode;
  renderRowActions?: (row: SpreadsheetRow, rowIndex: number) => ReactNode;
  ref?: Ref<HTMLDivElement>;
}

export const Spreadsheet = ({
  className,
  style,
  columns,
  showRowNumbers = true,
  renderRowNumber,
  renderRowActions,
  ref,
  ...props
}: SpreadsheetProps) => {
  "use no memo";

  // TanStack Virtual exposes mutable measurements that React Compiler cannot memoize.
  const data = useSpreadsheetStore((state) => state.data);
  const selectedCells = useSpreadsheetStore((state) => state.selectedCells);
  const isDragging = useSpreadsheetStore((state) => state.dragStartCell !== null);
  const columnWidths = useSpreadsheetStore((state) => state.columnWidths);
  const setColumnWidths = useSpreadsheetStore((state) => state.setColumnWidths);
  const dragLineVisible = useSpreadsheetStore((state) => state.dragLineVisible);
  const setDragLineVisible = useSpreadsheetStore((state) => state.setDragLineVisible);

  const tableContainerRef = useRef<HTMLDivElement>(null);
  const dragLineRef = useRef<HTMLDivElement>(null);

  const table = useTable({
    columns,
    data,
    features: spreadsheetFeatures,
  });

  const leafColumns = table.getAllLeafColumns();
  const columnMeta = useMemo<ColumnInfo[]>(
    () => leafColumns.map((column) => ({ id: column.id })),
    [leafColumns],
  );

  const navigationMap = useMemo<Map<string, NavigationMap>>(
    () => createNavigationMap(data, columnMeta),
    [data, columnMeta],
  );

  const columnSizeVars = useMemo(() => getColumnSizeVars(columnWidths), [columnWidths]);

  const { handleMouseDown, handleMouseMove, handleMouseUp, handleKeyDown } = useSpreadsheetHandlers(
    {
      columns: columnMeta,
      navigationMap,
    },
  );

  const handleColumnResize = useCallback(
    (columnId: string, width: number) => {
      setColumnWidths((prev) => ({
        ...prev,
        [columnId]: Math.max(MIN_COLUMN_WIDTH, width),
      }));
    },
    [setColumnWidths],
  );

  const getRowCellsHelper = useCallback(
    (rowId: string) => getRowCells(rowId, columnMeta),
    [columnMeta],
  );

  // Mouseup should be global to handle drag ending outside the spreadsheet
  useEffect(() => {
    document.addEventListener("mouseup", handleMouseUp);
    return () => {
      document.removeEventListener("mouseup", handleMouseUp);
    };
  }, [handleMouseUp]);

  // oxlint-disable-next-line react/incompatible-library -- This component and its table body opt out of memoization for mutable virtualizer measurements.
  const rowVirtualizer = useVirtualizer({
    count: table.getRowModel().rows.length,
    estimateSize: () => 36,
    getScrollElement: () => tableContainerRef.current,
    overscan: 10,
  });

  return (
    <div
      ref={ref}
      role="grid"
      className={cn("relative flex flex-col", isDragging && "select-none", className)}
      style={{ ...columnSizeVars, ...style }}
      tabIndex={0}
      onKeyDown={handleKeyDown}
      {...props}
    >
      <div
        ref={dragLineRef}
        className={cn(
          "bg-(--primary) pointer-events-none absolute top-0 bottom-0 z-50 w-0.5 transition-opacity",
          dragLineVisible ? "opacity-100" : "opacity-0",
        )}
      />

      <div className="bg-(--muted) border-(--border) sticky top-0 z-1 flex border-y">
        {showRowNumbers && (
          <div className="border-(--border) bg-(--muted) text-(--muted-foreground) flex h-10 w-12 shrink-0 items-center justify-center border-r text-xs font-medium">
            #
          </div>
        )}
        {(table.getHeaderGroups()[0]?.headers ?? []).map((header) => (
          <div
            key={header.id}
            role="presentation"
            data-column-id={header.column.id}
            data-column-header
            className="border-(--border) bg-(--muted) text-(--muted-foreground) relative flex h-10 shrink-0 cursor-default items-center border-r pl-1 text-left text-xs font-medium transition-colors"
            style={{
              width: `calc(var(--col-${header.column.id}-size) * 1px)`,
            }}
            onMouseDown={(e) => handleMouseDown(e, "", header.column.id)}
            onMouseMove={(e) => handleMouseMove(e, "", header.column.id)}
          >
            {header.isPlaceholder
              ? null
              : flexRender(header.column.columnDef.header, header.getContext())}
            <ResizeHandle
              columnId={header.column.id}
              width={columnWidths[header.column.id] ?? 0}
              handleColumnResize={handleColumnResize}
              tableContainerRef={tableContainerRef}
              dragLineRef={dragLineRef}
              setDragLineVisible={setDragLineVisible}
            />
          </div>
        ))}
        {showRowNumbers && (
          <div className="border-(--border) bg-(--muted) text-(--muted-foreground) flex h-10 w-12 shrink-0 items-center justify-center border-r text-xs font-medium" />
        )}
      </div>

      <div ref={tableContainerRef} className="flex-1">
        <div
          style={{
            height: `${rowVirtualizer.getTotalSize()}px`,
            position: "relative",
          }}
        >
          <TableBody
            virtualItems={rowVirtualizer.getVirtualItems()}
            table={table}
            selectedCells={selectedCells}
            getRowCells={getRowCellsHelper}
            handleMouseDown={handleMouseDown}
            handleMouseMove={handleMouseMove}
            showRowNumbers={showRowNumbers}
            renderRowNumber={renderRowNumber}
            renderRowActions={renderRowActions}
          />
        </div>
      </div>
    </div>
  );
};
```

### `/components/status-bar.tsx`

```tsx
"use client";

import type { ComponentProps } from "react";
import { cn } from "cn";

import { useSpreadsheetStore } from "../lib/spreadsheet-store";

export type StatusBarProps = ComponentProps<"div">;

export const StatusBar = ({ className, ...props }: StatusBarProps) => (
  <div
    className={cn(
      "text-(--muted-foreground) bg-(--background) dark:bg-(--background)/95 sticky bottom-0 flex items-center justify-between border-t p-2 text-xs",
      className,
    )}
    {...props}
  />
);

export type StatusBarSectionProps = ComponentProps<"div">;

export const StatusBarSection = ({ className, ...props }: StatusBarSectionProps) => (
  <div className={cn("flex flex-1 items-center gap-2", className)} {...props} />
);

export const StatusBarMessage = () => {
  const editingCell = useSpreadsheetStore((state) => state.editingCell);
  const selectedCells = useSpreadsheetStore((state) => state.selectedCells);
  const data = useSpreadsheetStore((state) => state.data);
  const selectedCellsCount = selectedCells.size;

  if (editingCell) {
    const rowIndex = data.findIndex((row) => row.id === editingCell.rowId);
    const displayRowNumber = rowIndex === -1 ? "?" : rowIndex + 1;

    return (
      <span>
        Editing row {displayRowNumber}, column {editingCell.columnId}
      </span>
    );
  }

  if (selectedCellsCount > 0) {
    return (
      <span>
        Selected: {selectedCellsCount} cell
        {selectedCellsCount > 1 ? "s" : ""}
      </span>
    );
  }

  return <span>Click a cell to select</span>;
};

export const StatusBarSummary = () => {
  const data = useSpreadsheetStore((state) => state.data);
  // Rows carry an `id` alongside their cell values; only the latter are columns.
  const columnCount = Object.keys(data[0] ?? {}).filter((key) => key !== "id").length;

  return (
    <span>
      {data.length} rows × {columnCount} columns
    </span>
  );
};
```

### `/components/table-body.tsx`

```tsx
"use client";

import type { MouseEvent, ReactNode } from "react";
import { cn } from "cn";
import { flexRender } from "@tanstack/react-table";

import type { SpreadsheetRow } from "../lib/spreadsheet-store";
import type { SpreadsheetFeatures } from "../lib/spreadsheet-utils";
import type { Table } from "@tanstack/react-table";
import type { VirtualItem } from "@tanstack/react-virtual";

interface TableBodyProps {
  virtualItems: VirtualItem[];
  table: Table<SpreadsheetFeatures, SpreadsheetRow>;
  selectedCells: Set<string>;
  getRowCells: (rowId: string) => string[];
  handleMouseDown: (e: MouseEvent, rowId: string, columnId: string) => void;
  handleMouseMove: (e: MouseEvent, rowId: string, columnId: string) => void;
  showRowNumbers?: boolean;
  renderRowNumber?: (rowIndex: number) => ReactNode;
  renderRowActions?: (row: SpreadsheetRow, rowIndex: number) => ReactNode;
}

export const TableBody = ({
  virtualItems,
  table,
  selectedCells,
  getRowCells,
  handleMouseDown,
  handleMouseMove,
  showRowNumbers = true,
  renderRowNumber,
  renderRowActions,
}: TableBodyProps) => (
  <>
    {virtualItems.map((virtualRow) => {
      const row = table.getRowModel().rows[virtualRow.index];
      const rowIndex = virtualRow.index;
      const rowId = row?.original?.id;
      if (!rowId) {
        return null;
      }

      const rowCells = getRowCells(rowId);
      const isRowSelected = rowCells.every((cell) => selectedCells.has(cell));

      return (
        <div
          key={row.id}
          className={cn(
            "hover:bg-(--muted)/30 dark:hover:bg-(--muted)/20 absolute top-0 left-0 w-full transition-colors",
            isRowSelected && "bg-(--muted)/50 dark:bg-(--muted)/40",
          )}
          style={{
            height: `${virtualRow.size}px`,
            transform: `translateY(${virtualRow.start}px)`,
          }}
        >
          <div className="flex h-full">
            {showRowNumbers && (
              <div
                role="presentation"
                data-row-number
                className={cn(
                  "border-(--border) bg-(--muted)/30 text-(--muted-foreground) hover:bg-(--muted)/50 dark:hover:bg-(--muted)/40 flex h-9 w-12 shrink-0 cursor-default items-center justify-center border-r border-b font-mono text-xs transition-colors",
                  isRowSelected && "bg-(--muted) dark:bg-(--muted)/80",
                )}
                onMouseDown={(e) => handleMouseDown(e, rowId, "")}
                onMouseMove={(e) => handleMouseMove(e, rowId, "")}
              >
                {renderRowNumber ? renderRowNumber(rowIndex) : rowIndex + 1}
              </div>
            )}
            {row.getVisibleCells().map((cell) => {
              const cellKey = `${rowId}:${cell.column.id}`;
              const isCellSelected = selectedCells.has(cellKey);

              return (
                <div
                  key={cell.id}
                  role="presentation"
                  className={cn(
                    "border-(--border) relative flex h-9 shrink-0 cursor-default items-center border-r border-b transition-colors",
                    isCellSelected && "bg-blue-50 dark:bg-blue-950/50",
                  )}
                  style={{
                    width: `calc(var(--col-${cell.column.id}-size) * 1px)`,
                  }}
                  onMouseDown={(e) => handleMouseDown(e, rowId, cell.column.id)}
                  onMouseMove={(e) => handleMouseMove(e, rowId, cell.column.id)}
                >
                  {flexRender(cell.column.columnDef.cell, cell.getContext())}
                </div>
              );
            })}
            {renderRowActions && (
              <div className="border-(--border) flex h-9 shrink-0 items-center justify-center border-b">
                {renderRowActions(row.original, rowIndex)}
              </div>
            )}
          </div>
        </div>
      );
    })}
  </>
);
```

### `/components/toolbar.tsx`

```tsx
"use client";

import type { ComponentProps } from "react";
import { Button } from "./ui";
import { cn } from "cn";

export type ToolbarProps = ComponentProps<"div">;

export const Toolbar = ({ className, ...props }: ToolbarProps) => (
  <div className={cn("flex items-center gap-2 p-2", className)} {...props} />
);

export type ToolbarButtonProps = ComponentProps<typeof Button>;

export const ToolbarButton = (props: ToolbarButtonProps) => (
  <Button size="sm" variant="outline" {...props} />
);
```

### `/components/ui.tsx`

```tsx
"use client";

import type { ComponentProps } from "react";
import { Button as ButtonPrimitive } from "@base-ui/react/button";
import { Menu } from "@base-ui/react/menu";
import { cn } from "cn";
import "../spreadsheet.css";

type Styled<Props> = Omit<Props, "className"> & { className?: string };

export const Button = ({
  className,
  variant = "outline",
  size = "sm",
  ...props
}: Styled<ButtonPrimitive.Props> & {
  variant?: "outline" | "ghost";
  size?: "sm" | "icon";
}) => (
  <ButtonPrimitive
    className={cn(
      "inline-flex shrink-0 items-center justify-center gap-2 rounded-full text-sm font-medium outline-none transition-colors hover:bg-(--muted) focus-visible:ring-2 focus-visible:ring-(--ring) disabled:opacity-50",
      variant === "outline" && "border border-(--border) bg-(--background) shadow-xs",
      size === "sm" ? "h-8 px-2.5" : "size-9",
      className,
    )}
    {...props}
  />
);

export const Input = ({ className, ...props }: ComponentProps<"input">) => (
  <input
    className={cn(
      "h-9 w-full rounded-md border border-(--input) bg-transparent px-2.5 py-1 text-sm outline-none focus-visible:ring-2 focus-visible:ring-(--ring)",
      className,
    )}
    {...props}
  />
);

export const DropdownMenu = Menu.Root;
export const DropdownMenuTrigger = Menu.Trigger;

export const DropdownMenuContent = ({
  className,
  align = "start",
  ...props
}: Styled<Menu.Popup.Props> & Pick<Menu.Positioner.Props, "align">) => (
  <Menu.Portal>
    <Menu.Positioner align={align} sideOffset={4} className="spreadsheet isolate z-50">
      <Menu.Popup
        className={cn(
          "min-w-32 rounded-md bg-(--popover) p-1 text-(--popover-foreground) shadow-md ring-1 ring-(--foreground)/10 outline-none",
          className,
        )}
        {...props}
      />
    </Menu.Positioner>
  </Menu.Portal>
);

export const DropdownMenuItem = ({ className, ...props }: Styled<Menu.Item.Props>) => (
  <Menu.Item
    className={cn(
      "flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none data-highlighted:bg-(--accent) data-disabled:opacity-50",
      className,
    )}
    {...props}
  />
);
```

### `/lib/spreadsheet-store.tsx`

```tsx
"use client";

import { createContext, use, useMemo } from "react";
import type { ReactNode } from "react";
import { createStore, useStore } from "zustand";
import "../spreadsheet.css";

/** A cell holds the text typed into it, or `undefined` once cleared by Delete/Backspace. */
export type CellValue = string | undefined;

export interface SpreadsheetRow {
  id: string;
  [key: string]: CellValue;
}

export interface SpreadsheetStore {
  data: SpreadsheetRow[];

  selectedCells: Set<string>;
  setSelectedCells: (update: (prev: Set<string>) => Set<string>) => void;

  editingCell: { rowId: string; columnId: string } | null;
  setEditingCell: (cell: { rowId: string; columnId: string } | null) => void;

  dragStartCell: { rowId: string; columnId: string } | null;
  setDragStartCell: (cell: { rowId: string; columnId: string } | null) => void;

  columnWidths: Record<string, number>;
  setColumnWidths: (update: (prev: Record<string, number>) => Record<string, number>) => void;

  dragLineVisible: boolean;
  setDragLineVisible: (visible: boolean) => void;

  updateData: (rowId: string, columnId: string, value: CellValue) => void;
  updateSelectedCellsData: (value?: CellValue) => void;
  addRow: (onCreateRow?: (rowIndex: number) => SpreadsheetRow) => void;
  deleteRow: (rowId: string) => void;
}

const createSpreadsheetStore = (
  initialData: SpreadsheetRow[],
  initialColumnWidths: Record<string, number>,
) =>
  createStore<SpreadsheetStore>((set, get) => ({
    addRow: (onCreateRow) => {
      set((state) => {
        const nextRow = onCreateRow?.(state.data.length) ?? { id: crypto.randomUUID() };
        return { data: [...state.data, nextRow] };
      });
    },
    columnWidths: initialColumnWidths,
    data: initialData,
    deleteRow: (rowId) => {
      set((state) => ({
        data: state.data.filter((row) => row.id !== rowId),
        dragStartCell: state.dragStartCell?.rowId === rowId ? null : state.dragStartCell,
        editingCell: state.editingCell?.rowId === rowId ? null : state.editingCell,
        selectedCells: new Set(
          [...state.selectedCells].filter((key) => !key.startsWith(`${rowId}:`)),
        ),
      }));
    },
    dragLineVisible: false,
    dragStartCell: null,
    editingCell: null,
    selectedCells: new Set(),
    setColumnWidths: (widths) => {
      set((state) => ({
        columnWidths: widths(state.columnWidths),
      }));
    },
    setDragLineVisible: (visible) => set({ dragLineVisible: visible }),
    setDragStartCell: (cell) => set({ dragStartCell: cell }),
    setEditingCell: (cell) => set({ editingCell: cell }),
    setSelectedCells: (cells) => {
      set((state) => ({
        selectedCells: cells(state.selectedCells),
      }));
    },
    updateData: (rowId, columnId, value) => {
      set((state) => ({
        data: state.data.map((row) => {
          if (row.id === rowId) {
            return { ...row, [columnId]: value };
          }
          return row;
        }),
      }));
    },
    updateSelectedCellsData: (value) => {
      if (get().selectedCells.size === 0) {
        return;
      }

      set((state) => {
        const newData = [...state.data];
        for (const cellKey of state.selectedCells) {
          const [rowId, columnId] = cellKey.split(":");
          if (!rowId || !columnId) {
            continue;
          }
          const rowIndex = newData.findIndex((row) => row.id === rowId);
          const currentRow = newData[rowIndex];
          if (!currentRow) {
            continue;
          }

          newData[rowIndex] = { ...currentRow, [columnId]: value };
        }
        return { data: newData };
      });
    },
  }));

type SpreadsheetStoreApi = ReturnType<typeof createSpreadsheetStore>;
const SpreadsheetContext = createContext<SpreadsheetStoreApi | null>(null);

export const SpreadsheetProvider = ({
  initialData,
  initialColumnWidths,
  children,
}: {
  initialData: SpreadsheetRow[];
  initialColumnWidths: Record<string, number>;
  children: ReactNode;
}) => {
  const store = useMemo(
    () => createSpreadsheetStore(initialData, initialColumnWidths),
    [initialData, initialColumnWidths],
  );
  return (
    <SpreadsheetContext value={store}>
      <div className="spreadsheet">{children}</div>
    </SpreadsheetContext>
  );
};

export const useSpreadsheetApi = () => {
  const store = use(SpreadsheetContext);
  if (!store) {
    throw new Error("Spreadsheet components require SpreadsheetProvider");
  }
  return store;
};

export const useSpreadsheetStore = <T,>(selector: (state: SpreadsheetStore) => T): T =>
  useStore(useSpreadsheetApi(), selector);
```

### `/lib/spreadsheet-utils.ts`

```ts
import { columnVisibilityFeature, tableFeatures } from "@tanstack/react-table";

import type { SpreadsheetRow } from "./spreadsheet-store";

/**
 * Features every spreadsheet table is built on. Features are opt-in in
 * react-table v9, and the body renders through `row.getVisibleCells()`, which
 * column visibility provides. Declared here rather than in `spreadsheet.tsx` so
 * the body and cell components can type against it without an import cycle.
 */
export const spreadsheetFeatures = tableFeatures({ columnVisibilityFeature });

export type SpreadsheetFeatures = typeof spreadsheetFeatures;

/** Columns never resize below this, both while dragging and once committed. */
export const MIN_COLUMN_WIDTH = 60;

export interface CellPosition {
  rowId: string;
  columnId: string;
}

export interface ColumnInfo {
  id: string;
}

export interface NavigationMap {
  up: string | null;
  down: string | null;
  left: string | null;
  right: string | null;
  tab: string | null;
}

export type NavigationDirection = keyof NavigationMap;

export const getRowCells = (rowId: string, columns: ColumnInfo[]): string[] =>
  columns.map((col) => `${rowId}:${col.id}`);

export const getColumnCells = (columnId: string, data: SpreadsheetRow[]): string[] =>
  data.map((row) => `${row.id}:${columnId}`);

export const getRangeCells = (
  startRowId: string,
  startColId: string,
  endRowId: string,
  endColId: string,
  columns: ColumnInfo[],
  data: SpreadsheetRow[],
): string[] => {
  const columnIds = columns.map((col) => col.id);
  const startColIndex = columnIds.indexOf(startColId);
  const endColIndex = columnIds.indexOf(endColId);

  const minColIndex = Math.min(startColIndex, endColIndex);
  const maxColIndex = Math.max(startColIndex, endColIndex);

  const startRowIndex = data.findIndex((row) => row.id === startRowId);
  const endRowIndex = data.findIndex((row) => row.id === endRowId);

  if (startRowIndex === -1 || endRowIndex === -1 || startColIndex === -1 || endColIndex === -1) {
    return [];
  }

  const minRowIndex = Math.min(startRowIndex, endRowIndex);
  const maxRowIndex = Math.max(startRowIndex, endRowIndex);

  const cells: string[] = [];
  for (let rowIndex = minRowIndex; rowIndex <= maxRowIndex; rowIndex += 1) {
    const row = data[rowIndex];
    if (!row) {
      continue;
    }
    for (let colIndex = minColIndex; colIndex <= maxColIndex; colIndex += 1) {
      const colId = columnIds[colIndex];
      if (!colId) {
        continue;
      }
      cells.push(`${row.id}:${colId}`);
    }
  }
  return cells;
};

export const toggleRowSelection = (
  rowId: string,
  selectedCells: Set<string>,
  columns: ColumnInfo[],
): Set<string> => {
  const rowCells = getRowCells(rowId, columns);
  const isRowFullySelected = rowCells.every((cell) => selectedCells.has(cell));

  const newSelectedCells = new Set(selectedCells);
  for (const cell of rowCells) {
    if (isRowFullySelected) {
      newSelectedCells.delete(cell);
    } else {
      newSelectedCells.add(cell);
    }
  }
  return newSelectedCells;
};

export const toggleColumnSelection = (
  columnId: string,
  selectedCells: Set<string>,
  data: SpreadsheetRow[],
): Set<string> => {
  const columnCells = getColumnCells(columnId, data);
  const isColumnFullySelected = columnCells.every((cell) => selectedCells.has(cell));

  const newSelectedCells = new Set(selectedCells);
  for (const cell of columnCells) {
    if (isColumnFullySelected) {
      newSelectedCells.delete(cell);
    } else {
      newSelectedCells.add(cell);
    }
  }
  return newSelectedCells;
};

export const toggleCellSelection = (cellKey: string, selectedCells: Set<string>): Set<string> => {
  const newSelectedCells = new Set(selectedCells);
  if (newSelectedCells.has(cellKey)) {
    newSelectedCells.delete(cellKey);
  } else {
    newSelectedCells.add(cellKey);
  }
  return newSelectedCells;
};

export const getFirstSelectedCell = (selectedCells: Set<string>): CellPosition | null => {
  const [firstSelectedCell] = [...selectedCells];
  if (!firstSelectedCell) {
    return null;
  }

  const [rowId, columnId] = firstSelectedCell.split(":");
  if (!rowId || !columnId) {
    return null;
  }
  return { columnId, rowId };
};

export const getColumnSizeVars = (columnWidths: Record<string, number>) => {
  const colSizes: { [key: string]: number } = {};
  for (const [columnId, width] of Object.entries(columnWidths)) {
    colSizes[`--col-${columnId}-size`] = width;
  }
  return colSizes;
};

export const isWithinDataAttribute = (target: EventTarget | null, attribute: string): boolean =>
  target instanceof HTMLElement && !!target.closest(`[data-${attribute}]`);

export const shouldAllowEditing = (selectedCells: Set<string>, cellKey: string): boolean =>
  selectedCells.has(cellKey) && selectedCells.size === 1;

export const createNavigationMap = (
  data: SpreadsheetRow[],
  columns: ColumnInfo[],
): Map<string, NavigationMap> => {
  const navigationMap = new Map<string, NavigationMap>();
  const columnIds = columns.map((col) => col.id);

  for (const [rowIndex, row] of data.entries()) {
    for (const [colIndex, columnId] of columnIds.entries()) {
      const cellKey = `${row.id}:${columnId}`;
      const prevRowId = data[rowIndex - 1]?.id;
      const nextRowId = data[rowIndex + 1]?.id;
      const prevColId = columnIds[colIndex - 1];
      const nextColId = columnIds[colIndex + 1];
      const [firstColId] = columnIds;

      const navigation: NavigationMap = {
        down: nextRowId ? `${nextRowId}:${columnId}` : null,
        left: prevColId ? `${row.id}:${prevColId}` : null,
        right: nextColId ? `${row.id}:${nextColId}` : null,
        tab: null,
        up: prevRowId ? `${prevRowId}:${columnId}` : null,
      };

      if (nextColId) {
        navigation.tab = `${row.id}:${nextColId}`;
      } else if (nextRowId && firstColId) {
        navigation.tab = `${nextRowId}:${firstColId}`;
      }

      navigationMap.set(cellKey, navigation);
    }
  }

  return navigationMap;
};

export const getNextCellPositionFromMap = (
  cellKey: string,
  direction: NavigationDirection,
  navigationMap: Map<string, NavigationMap>,
): CellPosition | null => {
  const navigation = navigationMap.get(cellKey);
  if (!navigation) {
    return null;
  }

  const nextCellKey = navigation[direction];
  if (!nextCellKey) {
    return null;
  }

  const [rowId, columnId] = nextCellKey.split(":");
  if (!rowId || !columnId) {
    return null;
  }
  return { columnId, rowId };
};
```

### `/lib/use-ai-enrichment.ts`

```ts
import { useCallback, useMemo } from "react";
import { useChat } from "@ai-sdk/react";
import { StaticChatTransport } from "@loremllm/transport";

import type { StoreApi } from "zustand";
import type { SpreadsheetStore } from "./spreadsheet-store";
import type { UIMessage } from "@ai-sdk/react";
import { useSpreadsheetApi } from "./spreadsheet-store";

type CustomUIMessage = UIMessage<
  unknown,
  {
    updateCell: {
      rowId: string;
      columnId: string;
      value: string;
    };
  }
>;

// The random index is always in range; the `?? list[0]` only satisfies
// `noUncheckedIndexedAccess`, which the non-empty tuple keeps well-typed.
const pick = <T>(list: readonly [T, ...T[]]): T =>
  list[Math.floor(Math.random() * list.length)] ?? list[0];

const fakeData = new Map<string, () => string>([
  [
    "firstName",
    () => pick(["John", "Jane", "Michael", "Sarah", "David", "Emily", "James", "Emma"]),
  ],
  [
    "lastName",
    () => pick(["Smith", "Johnson", "Williams", "Brown", "Jones", "Garcia", "Miller", "Davis"]),
  ],
  [
    "email",
    () => {
      const domain = pick(["example.com", "demo.org", "test.io"]);
      return `${Math.random().toString(36).slice(2, 15)}@${domain}`;
    },
  ],
  [
    "company",
    () =>
      pick([
        "Acme Corp",
        "Tech Solutions",
        "Global Industries",
        "Digital Ventures",
        "Innovation Labs",
        "Future Systems",
      ]),
  ],
  [
    "role",
    () =>
      pick([
        "Software Engineer",
        "Product Manager",
        "Data Scientist",
        "Designer",
        "Marketing Director",
        "Sales Executive",
      ]),
  ],
]);

const createTransport = (storeApi: StoreApi<SpreadsheetStore>) =>
  new StaticChatTransport<CustomUIMessage>({
    chunkDelayMs: [400, 1400],
    async *mockResponse() {
      const store = storeApi.getState();
      const { selectedCells } = store;
      const cells = [...selectedCells];

      for (const cellKey of cells) {
        const [rowId, columnId] = cellKey.split(":");
        if (!rowId || !columnId) {
          continue;
        }
        const generateValue = fakeData.get(columnId);
        if (!generateValue) {
          continue;
        }
        const value = generateValue();
        store.updateData(rowId, columnId, "Generating...");

        yield {
          data: { columnId, rowId, value },
          type: "data-updateCell",
        };
      }
    },
  });

export const useAiEnrichment = () => {
  const storeApi = useSpreadsheetApi();
  const transport = useMemo(() => createTransport(storeApi), [storeApi]);

  const { sendMessage, status } = useChat<CustomUIMessage>({
    onData: (dataPart) => {
      if (dataPart.type === "data-updateCell") {
        const { rowId, columnId, value } = dataPart.data;
        const store = storeApi.getState();
        store.updateData(rowId, columnId, value);
      }
    },
    transport,
  });

  const handleEnrich = useCallback(() => {
    void sendMessage({ text: "enrich" });
  }, [sendMessage]);

  const aiGenerating = status === "streaming" || status === "submitted";

  return {
    aiGenerating,
    handleEnrich,
  };
};
```

### `/lib/use-spreadsheet-handlers.ts`

```ts
import { useCallback } from "react";
import type { KeyboardEvent, MouseEvent } from "react";
import type { ColumnInfo, NavigationDirection, NavigationMap } from "./spreadsheet-utils";
import { useSpreadsheetStore } from "./spreadsheet-store";
import {
  getColumnCells,
  getFirstSelectedCell,
  getNextCellPositionFromMap,
  getRangeCells,
  getRowCells,
  isWithinDataAttribute,
  shouldAllowEditing,
  toggleCellSelection,
  toggleColumnSelection,
  toggleRowSelection,
} from "./spreadsheet-utils";

interface UseSpreadsheetHandlersProps {
  columns: ColumnInfo[];
  navigationMap: Map<string, NavigationMap>;
}

/** Keys that move the single-cell selection, mapped to their direction in the navigation map. */
const NAVIGATION_KEYS = new Map<string, NavigationDirection>([
  ["ArrowUp", "up"],
  ["ArrowDown", "down"],
  ["ArrowLeft", "left"],
  ["ArrowRight", "right"],
  ["Tab", "tab"],
]);

export const useSpreadsheetHandlers = ({ columns, navigationMap }: UseSpreadsheetHandlersProps) => {
  const data = useSpreadsheetStore((state) => state.data);
  const selectedCells = useSpreadsheetStore((state) => state.selectedCells);
  const setSelectedCells = useSpreadsheetStore((state) => state.setSelectedCells);
  const editingCell = useSpreadsheetStore((state) => state.editingCell);
  const setEditingCell = useSpreadsheetStore((state) => state.setEditingCell);
  const dragStartCell = useSpreadsheetStore((state) => state.dragStartCell);
  const setDragStartCell = useSpreadsheetStore((state) => state.setDragStartCell);
  const updateSelectedCellsData = useSpreadsheetStore((state) => state.updateSelectedCellsData);

  /** A multi-cell selection has no single edit target, so editing is cancelled. Returns `selection` for chaining. */
  const exitEditIfMultiple = useCallback(
    (selection: Set<string>) => {
      if (selection.size > 1 && editingCell) {
        setEditingCell(null);
      }
      return selection;
    },
    [editingCell, setEditingCell],
  );

  const handleMouseDown = useCallback(
    (e: MouseEvent, rowId: string, columnId: string) => {
      if (e.button !== 0) {
        return;
      }

      const cellKey = `${rowId}:${columnId}`;

      if (isWithinDataAttribute(e.target, "row-number")) {
        if (e.ctrlKey || e.metaKey) {
          setSelectedCells((currentSelectedCells) =>
            exitEditIfMultiple(toggleRowSelection(rowId, currentSelectedCells, columns)),
          );
        } else {
          setSelectedCells(() => exitEditIfMultiple(new Set(getRowCells(rowId, columns))));
        }
        return;
      }

      if (isWithinDataAttribute(e.target, "column-header")) {
        if (e.ctrlKey || e.metaKey) {
          setSelectedCells((currentSelectedCells) =>
            exitEditIfMultiple(toggleColumnSelection(columnId, currentSelectedCells, data)),
          );
        } else {
          setSelectedCells(() => exitEditIfMultiple(new Set(getColumnCells(columnId, data))));
        }
        return;
      }

      if (e.ctrlKey || e.metaKey) {
        setSelectedCells((currentSelectedCells) =>
          exitEditIfMultiple(toggleCellSelection(cellKey, currentSelectedCells)),
        );
      } else if (e.shiftKey) {
        setSelectedCells((currentSelectedCells) => {
          if (currentSelectedCells.size === 0) {
            return new Set([cellKey]);
          }

          const [firstSelectedCell] = [...currentSelectedCells];
          if (!firstSelectedCell) {
            return new Set([cellKey]);
          }
          const [firstRowId, firstCol] = firstSelectedCell.split(":");
          if (!firstRowId || !firstCol) {
            return new Set([cellKey]);
          }
          const rangeCells = getRangeCells(firstRowId, firstCol, rowId, columnId, columns, data);
          return exitEditIfMultiple(new Set(rangeCells));
        });
      } else {
        const isCurrentlyEditing =
          editingCell?.rowId === rowId && editingCell?.columnId === columnId;
        const isCurrentlySelected = selectedCells.has(cellKey) && selectedCells.size === 1;

        if (isCurrentlyEditing) {
          setTimeout(() => setEditingCell(null), 0);
        } else if (isCurrentlySelected) {
          setTimeout(() => setEditingCell({ columnId, rowId }), 0);
        } else {
          setSelectedCells(() => new Set([cellKey]));
        }
      }

      setDragStartCell({ columnId, rowId });
    },
    [
      selectedCells,
      columns,
      data,
      editingCell,
      exitEditIfMultiple,
      setSelectedCells,
      setEditingCell,
      setDragStartCell,
    ],
  );

  const handleMouseMove = useCallback(
    (e: MouseEvent, rowId: string, columnId: string) => {
      if (!dragStartCell) {
        return;
      }

      const rangeCells = getRangeCells(
        dragStartCell.rowId,
        dragStartCell.columnId,
        rowId,
        columnId,
        columns,
        data,
      );
      setSelectedCells(() => exitEditIfMultiple(new Set(rangeCells)));
    },
    [dragStartCell, columns, data, exitEditIfMultiple, setSelectedCells],
  );

  const handleMouseUp = useCallback(() => {
    setDragStartCell(null);
  }, [setDragStartCell]);

  const handleKeyDown = useCallback(
    (e: KeyboardEvent) => {
      const firstSelectedCell = getFirstSelectedCell(selectedCells);
      if (!firstSelectedCell) {
        return;
      }

      const { rowId, columnId } = firstSelectedCell;

      const { activeElement } = document;
      if (activeElement && activeElement.tagName === "INPUT") {
        return;
      }

      const direction = NAVIGATION_KEYS.get(e.key);
      if (direction) {
        e.preventDefault();
        const nextPosition = getNextCellPositionFromMap(
          `${rowId}:${columnId}`,
          direction,
          navigationMap,
        );
        if (nextPosition) {
          setSelectedCells(() => new Set([`${nextPosition.rowId}:${nextPosition.columnId}`]));
        }
        return;
      }

      switch (e.key) {
        case "Enter": {
          e.preventDefault();
          if (editingCell?.rowId === rowId && editingCell?.columnId === columnId) {
            setEditingCell(null);
          } else if (shouldAllowEditing(selectedCells, `${rowId}:${columnId}`)) {
            setEditingCell({ columnId, rowId });
          }
          break;
        }
        case "Escape": {
          e.preventDefault();
          if (editingCell) {
            setEditingCell(null);
          } else {
            setSelectedCells(() => new Set());
          }
          break;
        }
        case "Delete":
        case "Backspace": {
          e.preventDefault();
          if (selectedCells.size > 0) {
            updateSelectedCellsData();
          }
          break;
        }
        default: {
          break;
        }
      }
    },
    [
      selectedCells,
      editingCell,
      setEditingCell,
      setSelectedCells,
      updateSelectedCellsData,
      navigationMap,
    ],
  );

  return {
    handleKeyDown,
    handleMouseDown,
    handleMouseMove,
    handleMouseUp,
  };
};
```

### `/preview.tsx`

```tsx
"use client";

import {
  Button,
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from "./components/ui";

import { MoreVerticalIcon, Plus, Sparkles, TrashIcon } from "lucide-react";

import type { SpreadsheetProps } from "./components/spreadsheet";
import { EditableCell } from "./components/editable-cell";
import { Spreadsheet } from "./components/spreadsheet";
import {
  StatusBar,
  StatusBarMessage,
  StatusBarSection,
  StatusBarSummary,
} from "./components/status-bar";
import { Toolbar, ToolbarButton } from "./components/toolbar";
import type { SpreadsheetRow } from "./lib/spreadsheet-store";
import { SpreadsheetProvider, useSpreadsheetStore } from "./lib/spreadsheet-store";
import { useAiEnrichment } from "./lib/use-ai-enrichment";

interface Person extends SpreadsheetRow {
  id: string;
  linkedinUrl: string;
  firstName: string;
  lastName: string;
  email: string;
  company: string;
  role: string;
}

const generateSamplePeople = (count: number): Person[] =>
  Array.from({ length: count }, (_, i) => ({
    company: "",
    email: "",
    firstName: "",
    id: `${i + 1}`,
    lastName: "",
    linkedinUrl: `https://linkedin.com/in/user-${i + 1}`,
    role: "",
  }));

const initialData = generateSamplePeople(30);
const initialColumnWidths = {
  company: 150,
  email: 180,
  firstName: 120,
  lastName: 120,
  linkedinUrl: 250,
  role: 150,
};

const RowActions = ({ row }: { row: SpreadsheetRow }) => {
  const deleteRow = useSpreadsheetStore((state) => state.deleteRow);

  return (
    <DropdownMenu>
      <DropdownMenuTrigger
        aria-label="Row actions"
        render={<Button size="icon" variant="ghost" className="h-8 w-8" />}
      >
        <MoreVerticalIcon className="h-3 w-3" />
      </DropdownMenuTrigger>
      <DropdownMenuContent align="end">
        <DropdownMenuItem onClick={() => deleteRow(row.id)} className="text-(--destructive)">
          <TrashIcon className="size-4" />
          Delete
        </DropdownMenuItem>
      </DropdownMenuContent>
    </DropdownMenu>
  );
};

const ToolbarButtons = () => {
  const { handleEnrich } = useAiEnrichment();
  const addRow = useSpreadsheetStore((state) => state.addRow);

  return (
    <>
      <ToolbarButton
        onClick={() =>
          addRow(() => ({
            company: "",
            email: "",
            firstName: "",
            id: crypto.randomUUID(),
            lastName: "",
            linkedinUrl: "",
            role: "",
          }))
        }
      >
        <Plus className="size-4" />
        Add Row
      </ToolbarButton>
      <ToolbarButton onClick={handleEnrich}>
        <Sparkles className="size-4" />
        Enrich
      </ToolbarButton>
    </>
  );
};

const columns: SpreadsheetProps["columns"] = [
  { accessorKey: "linkedinUrl", cell: EditableCell, header: "LinkedIn URL" },
  { accessorKey: "firstName", cell: EditableCell, header: "First Name" },
  { accessorKey: "lastName", cell: EditableCell, header: "Last Name" },
  { accessorKey: "email", cell: EditableCell, header: "Email" },
  { accessorKey: "company", cell: EditableCell, header: "Company" },
  { accessorKey: "role", cell: EditableCell, header: "Role" },
];

const Preview = () => (
  <SpreadsheetProvider initialData={initialData} initialColumnWidths={initialColumnWidths}>
    <Toolbar>
      <ToolbarButtons />
    </Toolbar>
    <Spreadsheet
      columns={columns}
      showRowNumbers
      renderRowActions={(row) => <RowActions row={row} />}
    />
    <StatusBar>
      <StatusBarSection>
        <StatusBarMessage />
      </StatusBarSection>
      <StatusBarSection className="justify-end">
        <StatusBarSummary />
      </StatusBarSection>
    </StatusBar>
  </SpreadsheetProvider>
);

export default Preview;
```

### `/spreadsheet.css`

```css
.spreadsheet {
  --background: #fff;
  --foreground: #18181b;
  --primary: #f66b2c;
  --primary-foreground: #fff;
  --secondary: #f4f4f5;
  --secondary-foreground: #27272a;
  --muted: #f4f4f5;
  --muted-foreground: #71717a;
  --accent: #f4f4f5;
  --accent-foreground: #27272a;
  --popover: #fff;
  --popover-foreground: #18181b;
  --border: #e4e4e7;
  --input: #e4e4e7;
  --ring: #a1a1aa;
  --destructive: #dc2626;
  color: var(--foreground);
}

.dark .spreadsheet {
  --background: #18181b;
  --foreground: #fafafa;
  --primary-foreground: #27272a;
  --secondary: #27272a;
  --secondary-foreground: #fafafa;
  --muted: #27272a;
  --muted-foreground: #a1a1aa;
  --accent: #27272a;
  --accent-foreground: #fafafa;
  --popover: #27272a;
  --popover-foreground: #fafafa;
  --border: #ffffff1a;
  --input: #ffffff26;
  --ring: #71717a;
  --destructive: #f87171;
}

@layer base {
  :where(.spreadsheet, .spreadsheet *) {
    border-color: var(--border);
  }
}
```

### `/styles.d.ts`

```ts
declare module "*.css" {}
```
