{"slug":"table","name":"Table","description":"Virtualized data table that stays smooth at 10k+ rows, with sortable headers, row selection, column resize and reorder, and a sticky header. Minimal, reduced-motion-safe motion.","category":"motion","source_url":"https://beui.dev/r/table/raw","detail_url":"https://beui.dev/r/table","raw_url":"https://beui.dev/r/table/raw","page_url":"https://beui.dev/components/motion/table","dependencies":["@tanstack/react-virtual","clsx","lucide-react","motion","react","react-dom","tailwind-merge"],"internal":["./editable-cell","./row-handle","./skeleton-rows","./table-header","./table-menu","./types","./use-column-reorder","./use-column-resize","./use-column-sort","./use-row-selection","./utils","@/components/motion/checkbox","@/components/motion/table","@/lib/ease","@/lib/utils"],"files":[{"path":"components/motion/table/index.tsx","type":"component","content":"\"use client\";\n// beui.dev/components/motion/table\n\nimport { useVirtualizer } from \"@tanstack/react-virtual\";\nimport { useReducedMotion } from \"motion/react\";\nimport { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport { Checkbox } from \"@/components/motion/checkbox\";\nimport { cn } from \"@/lib/utils\";\nimport { EditableCell } from \"./editable-cell\";\nimport { RowHandle } from \"./row-handle\";\nimport { SkeletonRows } from \"./skeleton-rows\";\nimport { TableHeader } from \"./table-header\";\nimport type { HeaderCellRefs, TableProps } from \"./types\";\nimport { useColumnReorder } from \"./use-column-reorder\";\nimport { useColumnResize } from \"./use-column-resize\";\nimport { useColumnSort } from \"./use-column-sort\";\nimport { useRowSelection } from \"./use-row-selection\";\nimport { CHECKBOX_WIDTH, alignText, readCell } from \"./utils\";\n\nexport type {\n  SortDirection,\n  SortState,\n  TableColumn,\n  TableProps,\n} from \"./types\";\n\nexport function Table<T>({\n  data,\n  columns,\n  getRowId,\n  selectable = false,\n  selectedRowIds,\n  defaultSelectedRowIds,\n  onSelectionChange,\n  sort: sortProp,\n  defaultSort = null,\n  onSortChange,\n  resizable = false,\n  minColumnWidth = 64,\n  onColumnResize,\n  reorderable = false,\n  onColumnOrderChange,\n  onCellEdit,\n  onColumnRename,\n  onInsertRow,\n  onDeleteRow,\n  onInsertColumn,\n  onDeleteColumn,\n  rowHeight = 48,\n  height = 440,\n  overscan = 10,\n  onEndReached,\n  loading = false,\n  skeletonRows = 3,\n  emptyState = \"No data\",\n  className,\n}: TableProps<T>) {\n  const reduce = useReducedMotion();\n  const scrollRef = useRef<HTMLDivElement>(null);\n  const thRefs: HeaderCellRefs = useRef<\n    Record<string, HTMLTableCellElement | null>\n  >({});\n\n  const rows = useMemo(\n    () =>\n      data.map((row, index) => ({\n        row,\n        id: getRowId ? getRowId(row, index) : String(index),\n      })),\n    [data, getRowId],\n  );\n\n  const {\n    orderedColumns,\n    dragKey,\n    dropIndex,\n    startReorder,\n    moveReorder,\n    endReorder,\n  } = useColumnReorder({ columns, thRefs, onColumnOrderChange });\n\n  const { sort, sortedRows, toggleSort } = useColumnSort({\n    rows,\n    columns,\n    sort: sortProp,\n    defaultSort,\n    onSortChange,\n  });\n\n  const { widths, startResize, moveResize, endResize } = useColumnResize({\n    orderedColumns,\n    thRefs,\n    minColumnWidth,\n    onColumnResize,\n  });\n\n  const { selected, allSelected, someSelected, toggleAll, toggleRow } =\n    useRowSelection({\n      sortedRows,\n      selectedRowIds,\n      defaultSelectedRowIds,\n      onSelectionChange,\n    });\n\n  const virtualizer = useVirtualizer({\n    count: sortedRows.length,\n    getScrollElement: () => scrollRef.current,\n    estimateSize: () => rowHeight,\n    overscan,\n  });\n\n  const virtualItems = virtualizer.getVirtualItems();\n  const totalSize = virtualizer.getTotalSize();\n  const paddingTop = virtualItems.length > 0 ? virtualItems[0].start : 0;\n  const paddingBottom =\n    virtualItems.length > 0\n      ? totalSize - virtualItems[virtualItems.length - 1].end\n      : 0;\n\n  const hasRowMenu = !!(onInsertRow || onDeleteRow);\n  const hasColumnMenu = !!(onInsertColumn || onDeleteColumn);\n  // Only shrink-wrap (w-max) once every column has an explicit resized width;\n  // otherwise stay fill-width so a flexible column can't size to cell content.\n  const sized =\n    orderedColumns.length > 0 &&\n    orderedColumns.every((c) => widths[c.key] != null);\n\n  // Infinite scroll: fire onEndReached once per near-bottom dwell, paused while\n  // loading; the guard resets when the load completes.\n  const endReachedRef = useRef(false);\n  useEffect(() => {\n    if (!loading) endReachedRef.current = false;\n  }, [loading]);\n  const handleScroll = useCallback(() => {\n    const el = scrollRef.current;\n    if (!el || !onEndReached || loading || endReachedRef.current) return;\n    if (el.scrollHeight - el.scrollTop - el.clientHeight < rowHeight * 4) {\n      endReachedRef.current = true;\n      onEndReached();\n    }\n  }, [onEndReached, loading, rowHeight]);\n  const [activeColumn, setActiveColumn] = useState<string | null>(null);\n  // Small delay on leave so the pointer can cross the gap from the header cell\n  // to the portal handle without the column deactivating.\n  const deactivateTimer = useRef<ReturnType<typeof setTimeout> | null>(null);\n  const activateColumn = useCallback((key: string) => {\n    if (deactivateTimer.current) clearTimeout(deactivateTimer.current);\n    deactivateTimer.current = null;\n    setActiveColumn(key);\n  }, []);\n  const deactivateColumn = useCallback(() => {\n    if (deactivateTimer.current) clearTimeout(deactivateTimer.current);\n    deactivateTimer.current = setTimeout(() => setActiveColumn(null), 100);\n  }, []);\n\n  const rowRefs = useRef<Record<string, HTMLTableRowElement | null>>({});\n  const [activeRow, setActiveRow] = useState<{ id: string; index: number } | null>(\n    null,\n  );\n  const rowTimer = useRef<ReturnType<typeof setTimeout> | null>(null);\n  const activateRow = useCallback((id: string, index: number) => {\n    if (rowTimer.current) clearTimeout(rowTimer.current);\n    rowTimer.current = null;\n    setActiveRow({ id, index });\n  }, []);\n  const deactivateRow = useCallback(() => {\n    if (rowTimer.current) clearTimeout(rowTimer.current);\n    rowTimer.current = setTimeout(() => setActiveRow(null), 100);\n  }, []);\n  const activeRowEl = activeRow ? rowRefs.current[activeRow.id] : null;\n  // Real columns + checkbox; the trailing spacer adds one more in colSpans.\n  const leadColumns = columns.length + (selectable ? 1 : 0);\n\n  return (\n    <div\n      className={cn(\n        \"w-full overflow-hidden border border-border bg-background text-sm\",\n        className,\n      )}\n    >\n      <div\n        ref={scrollRef}\n        onScroll={handleScroll}\n        className=\"overflow-auto\"\n        style={{ height }}\n      >\n        <table\n          className={cn(\"border-collapse\", sized ? \"w-max min-w-full\" : \"min-w-full\")}\n          style={{ tableLayout: \"fixed\" }}\n        >\n          <colgroup>\n            {selectable ? <col style={{ width: CHECKBOX_WIDTH }} /> : null}\n            {orderedColumns.map((column) => {\n              const override = widths[column.key];\n              const width = override ? `${override}px` : column.width;\n              return (\n                <col key={column.key} style={width ? { width } : undefined} />\n              );\n            })}\n            {/* Empty filler owns the leftover space — no gap, content unpinned. */}\n            <col />\n          </colgroup>\n\n          <TableHeader\n            columns={orderedColumns}\n            rowHeight={rowHeight}\n            reduce={!!reduce}\n            thRefs={thRefs}\n            selectable={selectable}\n            allSelected={allSelected}\n            someSelected={someSelected}\n            onToggleAll={toggleAll}\n            sort={sort}\n            onToggleSort={toggleSort}\n            resizable={resizable}\n            onResizeStart={startResize}\n            onResizeMove={moveResize}\n            onResizeEnd={endResize}\n            reorderable={reorderable}\n            dragKey={dragKey}\n            dropIndex={dropIndex}\n            onReorderStart={startReorder}\n            onReorderMove={moveReorder}\n            onReorderEnd={endReorder}\n            onInsertColumn={onInsertColumn}\n            onDeleteColumn={onDeleteColumn}\n            onColumnRename={onColumnRename}\n            activeColumn={hasColumnMenu ? activeColumn : null}\n            onColumnActivate={hasColumnMenu ? activateColumn : undefined}\n            onColumnDeactivate={hasColumnMenu ? deactivateColumn : undefined}\n          />\n\n          <tbody>\n            {sortedRows.length === 0 ? (\n              loading ? (\n                <SkeletonRows\n                  count={Math.max(1, Math.ceil(height / rowHeight))}\n                  columns={orderedColumns}\n                  selectable={selectable}\n                  rowHeight={rowHeight}\n                />\n              ) : (\n                <tr>\n                  <td\n                    colSpan={leadColumns + 1}\n                    className=\"p-10 text-center text-muted-foreground\"\n                  >\n                    {emptyState}\n                  </td>\n                </tr>\n              )\n            ) : (\n              <>\n                {paddingTop > 0 ? (\n                  <tr aria-hidden style={{ height: paddingTop }}>\n                    <td colSpan={leadColumns + 1} />\n                  </tr>\n                ) : null}\n                {virtualItems.map((vItem) => {\n                  const entry = sortedRows[vItem.index];\n                  const isSelected = selected.has(entry.id);\n                  return (\n                    <tr\n                      key={entry.id}\n                      ref={(el) => {\n                        rowRefs.current[entry.id] = el;\n                      }}\n                      data-selected={isSelected}\n                      style={{ height: rowHeight }}\n                      onPointerEnter={\n                        hasRowMenu\n                          ? () => activateRow(entry.id, vItem.index)\n                          : undefined\n                      }\n                      onPointerLeave={hasRowMenu ? deactivateRow : undefined}\n                      className={cn(\n                        \"border-border/60 border-b transition-colors\",\n                        \"data-[selected=true]:bg-primary/5\",\n                        \"hover:bg-muted/50\",\n                      )}\n                    >\n                      {selectable ? (\n                        <td className=\"text-center\">\n                          <div className=\"flex items-center justify-center\">\n                            <Checkbox\n                              checked={isSelected}\n                              onCheckedChange={() => toggleRow(entry.id)}\n                              aria-label={`Select row ${vItem.index + 1}`}\n                            />\n                          </div>\n                        </td>\n                      ) : null}\n                      {orderedColumns.map((column) => (\n                        <td\n                          key={column.key}\n                          className={cn(\n                            \"truncate px-4 text-foreground\",\n                            alignText(column.align),\n                          )}\n                        >\n                          {!column.cell && column.editable ? (\n                            <EditableCell\n                              value={String(readCell(entry.row, column) ?? \"\")}\n                              label={`${column.key} for row ${vItem.index + 1}`}\n                              onChange={(next) =>\n                                onCellEdit?.(entry.id, column.key, next)\n                              }\n                            />\n                          ) : (\n                            readCell(entry.row, column)\n                          )}\n                        </td>\n                      ))}\n                      <td aria-hidden />\n                    </tr>\n                  );\n                })}\n                {paddingBottom > 0 ? (\n                  <tr aria-hidden style={{ height: paddingBottom }}>\n                    <td colSpan={leadColumns + 1} />\n                  </tr>\n                ) : null}\n                {loading ? (\n                  <SkeletonRows\n                    count={skeletonRows}\n                    columns={orderedColumns}\n                    selectable={selectable}\n                    rowHeight={rowHeight}\n                  />\n                ) : null}\n              </>\n            )}\n          </tbody>\n        </table>\n      </div>\n      {hasRowMenu && activeRow ? (\n        <RowHandle\n          rowEl={activeRowEl}\n          id={activeRow.id}\n          index={activeRow.index}\n          onInsertRow={onInsertRow}\n          onDeleteRow={onDeleteRow}\n          onEnter={() => activateRow(activeRow.id, activeRow.index)}\n          onLeave={deactivateRow}\n        />\n      ) : null}\n    </div>\n  );\n}\n"},{"path":"components/motion/table/editable-cell.tsx","type":"util","content":"\"use client\";\n\nexport function EditableCell({\n  value,\n  label,\n  onChange,\n}: {\n  value: string;\n  label: string;\n  onChange: (next: string) => void;\n}) {\n  return (\n    <input\n      value={value}\n      aria-label={label}\n      size={1}\n      onChange={(e) => onChange(e.target.value)}\n      placeholder=\"Empty\"\n      className=\"-mx-2 w-full min-w-0 appearance-none rounded-md border-0 bg-transparent px-2 py-1 text-foreground outline-none transition-colors placeholder:text-muted-foreground/40 focus:bg-muted focus:ring-1 focus:ring-ring\"\n    />\n  );\n}\n"},{"path":"components/motion/table/row-handle.tsx","type":"util","content":"\"use client\";\n\nimport { ArrowDownToLine, ArrowUpToLine, MoreVertical, Trash2 } from \"lucide-react\";\nimport { useEffect } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { TableMenu } from \"./table-menu\";\n\n/** The row handle, portaled so it can sit on the row's left border without the\n * scroll container clipping it. Straddles the border to bridge hover. */\nexport function RowHandle({\n  rowEl,\n  id,\n  index,\n  onInsertRow,\n  onDeleteRow,\n  onEnter,\n  onLeave,\n}: {\n  rowEl: HTMLTableRowElement | null;\n  id: string;\n  index: number;\n  onInsertRow?: (index: number, position: \"before\" | \"after\") => void;\n  onDeleteRow?: (rowId: string, index: number) => void;\n  onEnter: () => void;\n  onLeave: () => void;\n}) {\n  useEffect(() => {\n    window.addEventListener(\"scroll\", onLeave, true);\n    return () => window.removeEventListener(\"scroll\", onLeave, true);\n  }, [onLeave]);\n\n  if (!rowEl || typeof document === \"undefined\") return null;\n  const rect = rowEl.getBoundingClientRect();\n\n  return createPortal(\n    <div\n      style={{\n        position: \"fixed\",\n        top: rect.top + rect.height / 2,\n        left: rect.left,\n        transform: \"translate(-50%, -50%)\",\n        zIndex: 40,\n      }}\n      onPointerEnter={onEnter}\n      onPointerLeave={onLeave}\n    >\n      <TableMenu\n        ariaLabel={`Row ${index + 1} options`}\n        triggerClassName=\"flex h-6 w-2 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-sm transition-colors hover:bg-primary/90\"\n        trigger={<MoreVertical className=\"h-3 w-3\" />}\n        items={[\n          ...(onInsertRow\n            ? [\n                {\n                  label: \"Insert before\",\n                  icon: <ArrowUpToLine />,\n                  onSelect: () => onInsertRow(index, \"before\"),\n                },\n                {\n                  label: \"Insert after\",\n                  icon: <ArrowDownToLine />,\n                  onSelect: () => onInsertRow(index, \"after\"),\n                },\n              ]\n            : []),\n          ...(onDeleteRow\n            ? [\n                {\n                  label: \"Delete row\",\n                  icon: <Trash2 />,\n                  destructive: true,\n                  onSelect: () => onDeleteRow(id, index),\n                },\n              ]\n            : []),\n        ]}\n      />\n    </div>,\n    document.body,\n  );\n}\n"},{"path":"components/motion/table/skeleton-rows.tsx","type":"util","content":"\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport type { TableColumn } from \"./types\";\nimport { alignText } from \"./utils\";\n\nexport function SkeletonRows<T>({\n  count,\n  columns,\n  selectable,\n  rowHeight,\n}: {\n  count: number;\n  columns: TableColumn<T>[];\n  selectable: boolean;\n  rowHeight: number;\n}) {\n  return (\n    <>\n      {Array.from({ length: count }, (_, r) => (\n        // biome-ignore lint/suspicious/noArrayIndexKey: static placeholder rows\n        <tr key={r} style={{ height: rowHeight }} className=\"border-border/60 border-b\">\n          {selectable ? <td /> : null}\n          {columns.map((column) => (\n            <td key={column.key} className={cn(\"px-4\", alignText(column.align))}>\n              <div\n                className={cn(\n                  \"h-3 animate-pulse rounded-full bg-muted\",\n                  column.align === \"right\" ? \"ml-auto w-10\" : \"w-2/3\",\n                )}\n              />\n            </td>\n          ))}\n          <td aria-hidden />\n        </tr>\n      ))}\n    </>\n  );\n}\n"},{"path":"components/motion/table/table-header.tsx","type":"util","content":"\"use client\";\n\nimport {\n  ArrowLeftToLine,\n  ArrowRightToLine,\n  ChevronUp,\n  GripVertical,\n  MoreHorizontal,\n  Trash2,\n} from \"lucide-react\";\nimport { motion } from \"motion/react\";\nimport { type PointerEvent as ReactPointerEvent, useEffect } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { Checkbox } from \"@/components/motion/checkbox\";\nimport { EASE_OUT, SPRING_PRESS } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\nimport { TableMenu } from \"./table-menu\";\nimport type {\n  HeaderCellRefs,\n  InsertPosition,\n  SortState,\n  TableColumn,\n} from \"./types\";\nimport { alignFlex, alignText, COLUMN_ACTIVE_SHADOW } from \"./utils\";\n\nexport interface TableHeaderProps<T> {\n  columns: TableColumn<T>[];\n  rowHeight: number;\n  reduce: boolean;\n  thRefs: HeaderCellRefs;\n  selectable: boolean;\n  allSelected: boolean;\n  someSelected: boolean;\n  onToggleAll: () => void;\n  sort: SortState | null;\n  onToggleSort: (key: string) => void;\n  resizable: boolean;\n  onResizeStart: (key: string, e: ReactPointerEvent) => void;\n  onResizeMove: (e: ReactPointerEvent) => void;\n  onResizeEnd: (e: ReactPointerEvent) => void;\n  reorderable: boolean;\n  dragKey: string | null;\n  dropIndex: number | null;\n  onReorderStart: (key: string, e: ReactPointerEvent) => void;\n  onReorderMove: (e: ReactPointerEvent) => void;\n  onReorderEnd: (e: ReactPointerEvent) => void;\n  onInsertColumn?: (index: number, position: InsertPosition) => void;\n  onDeleteColumn?: (columnKey: string, index: number) => void;\n  onColumnRename?: (columnKey: string, value: string) => void;\n  activeColumn: string | null;\n  onColumnActivate?: (key: string) => void;\n  onColumnDeactivate?: () => void;\n}\n\n/** Column insert / delete menu items shared by the header cell and the portal handle. */\nfunction columnMenuItems<T>(\n  column: TableColumn<T>,\n  index: number,\n  onInsertColumn?: (index: number, position: InsertPosition) => void,\n  onDeleteColumn?: (columnKey: string, index: number) => void,\n) {\n  return [\n    ...(onInsertColumn\n      ? [\n          {\n            label: \"Insert before\",\n            icon: <ArrowLeftToLine />,\n            onSelect: () => onInsertColumn(index, \"before\"),\n          },\n          {\n            label: \"Insert after\",\n            icon: <ArrowRightToLine />,\n            onSelect: () => onInsertColumn(index, \"after\"),\n          },\n        ]\n      : []),\n    ...(onDeleteColumn\n      ? [\n          {\n            label: \"Delete column\",\n            icon: <Trash2 />,\n            destructive: true,\n            onSelect: () => onDeleteColumn(column.key, index),\n          },\n        ]\n      : []),\n  ];\n}\n\n/** The ellipse handle, portaled so it can sit on the column's top border without\n * the scroll container clipping it. Straddles the border to bridge hover. */\nfunction ColumnHandle<T>({\n  column,\n  index,\n  thRefs,\n  onInsertColumn,\n  onDeleteColumn,\n  onEnter,\n  onLeave,\n}: {\n  column: TableColumn<T>;\n  index: number;\n  thRefs: HeaderCellRefs;\n  onInsertColumn?: (index: number, position: InsertPosition) => void;\n  onDeleteColumn?: (columnKey: string, index: number) => void;\n  onEnter: () => void;\n  onLeave: () => void;\n}) {\n  useEffect(() => {\n    window.addEventListener(\"scroll\", onLeave, true);\n    return () => window.removeEventListener(\"scroll\", onLeave, true);\n  }, [onLeave]);\n\n  const el = thRefs.current[column.key];\n  if (!el || typeof document === \"undefined\") return null;\n  const rect = el.getBoundingClientRect();\n\n  return createPortal(\n    <div\n      style={{\n        position: \"fixed\",\n        top: rect.top,\n        left: rect.left + rect.width / 2,\n        transform: \"translate(-50%, -50%)\",\n        zIndex: 40,\n      }}\n      onPointerEnter={onEnter}\n      onPointerLeave={onLeave}\n    >\n      <TableMenu\n        ariaLabel={`${column.key} column options`}\n        triggerClassName=\"flex h-2 w-6 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-sm transition-colors hover:bg-primary/90\"\n        trigger={<MoreHorizontal className=\"h-3 w-3\" />}\n        items={columnMenuItems(column, index, onInsertColumn, onDeleteColumn)}\n      />\n    </div>,\n    document.body,\n  );\n}\n\nexport function TableHeader<T>({\n  columns,\n  rowHeight,\n  reduce,\n  thRefs,\n  selectable,\n  allSelected,\n  someSelected,\n  onToggleAll,\n  sort,\n  onToggleSort,\n  resizable,\n  onResizeStart,\n  onResizeMove,\n  onResizeEnd,\n  reorderable,\n  dragKey,\n  dropIndex,\n  onReorderStart,\n  onReorderMove,\n  onReorderEnd,\n  onInsertColumn,\n  onDeleteColumn,\n  onColumnRename,\n  activeColumn,\n  onColumnActivate,\n  onColumnDeactivate,\n}: TableHeaderProps<T>) {\n  const hasColumnMenu = !!(onInsertColumn || onDeleteColumn);\n  const activeIndex = columns.findIndex((c) => c.key === activeColumn);\n  return (\n    <>\n      {hasColumnMenu && activeColumn && activeIndex >= 0 ? (\n        <ColumnHandle\n          column={columns[activeIndex]}\n          index={activeIndex}\n          thRefs={thRefs}\n          onInsertColumn={onInsertColumn}\n          onDeleteColumn={onDeleteColumn}\n          onEnter={() => onColumnActivate?.(activeColumn)}\n          onLeave={() => onColumnDeactivate?.()}\n        />\n      ) : null}\n      <thead>\n      <tr style={{ height: rowHeight }}>\n        {selectable ? (\n          <th className=\"sticky top-0 z-10 border-border border-b bg-muted\">\n            <div className=\"flex items-center justify-center\">\n              <Checkbox\n                checked={allSelected}\n                indeterminate={!allSelected && someSelected}\n                onCheckedChange={onToggleAll}\n                aria-label=\"Select all rows\"\n              />\n            </div>\n          </th>\n        ) : null}\n        {columns.map((column, index) => {\n          const active = sort?.key === column.key;\n          const isDragging = dragKey === column.key;\n          const isActive = activeColumn === column.key;\n          return (\n            <th\n              key={column.key}\n              ref={(el) => {\n                thRefs.current[column.key] = el;\n              }}\n              onPointerEnter={() => onColumnActivate?.(column.key)}\n              onPointerLeave={() => onColumnDeactivate?.()}\n              style={isActive ? { boxShadow: COLUMN_ACTIVE_SHADOW } : undefined}\n              aria-sort={\n                active\n                  ? sort?.direction === \"asc\"\n                    ? \"ascending\"\n                    : \"descending\"\n                  : undefined\n              }\n              data-drop={dragKey ? dropIndex === index : undefined}\n              data-dropend={\n                dragKey\n                  ? dropIndex === columns.length && index === columns.length - 1\n                  : undefined\n              }\n              className={cn(\n                \"group sticky top-0 z-10 border-border border-b bg-muted p-0 font-medium text-muted-foreground\",\n                \"data-[drop=true]:before:absolute data-[drop=true]:before:inset-y-0 data-[drop=true]:before:left-0 data-[drop=true]:before:w-0.5 data-[drop=true]:before:bg-primary\",\n                \"data-[dropend=true]:after:absolute data-[dropend=true]:after:inset-y-0 data-[dropend=true]:after:right-0 data-[dropend=true]:after:w-0.5 data-[dropend=true]:after:bg-primary\",\n              )}\n            >\n              <motion.div\n                className={cn(\n                  \"flex h-full items-center\",\n                  alignFlex(column.align),\n                )}\n                style={{ height: rowHeight }}\n                animate={\n                  reduce\n                    ? { opacity: isDragging ? 0.5 : 1 }\n                    : {\n                        scale: isDragging ? 1.04 : 1,\n                        opacity: isDragging ? 0.5 : 1,\n                      }\n                }\n                transition={SPRING_PRESS}\n              >\n                {reorderable ? (\n                  <button\n                    type=\"button\"\n                    aria-label={`Reorder ${column.key} column`}\n                    onPointerDown={(e) => onReorderStart(column.key, e)}\n                    onPointerMove={onReorderMove}\n                    onPointerUp={onReorderEnd}\n                    className=\"flex h-full cursor-grab touch-none items-center pl-2 text-muted-foreground/60 transition-colors hover:text-foreground active:cursor-grabbing\"\n                  >\n                    <GripVertical className=\"h-3.5 w-3.5\" />\n                  </button>\n                ) : null}\n                {column.sortable ? (\n                  <button\n                    type=\"button\"\n                    onClick={() => onToggleSort(column.key)}\n                    className={cn(\n                      \"flex h-full min-w-0 flex-1 select-none items-center gap-1 px-4 transition-colors hover:text-foreground\",\n                      alignFlex(column.align),\n                      active && \"text-foreground\",\n                    )}\n                  >\n                    <span className=\"truncate\">{column.header}</span>\n                    <motion.span\n                      aria-hidden\n                      className=\"inline-flex shrink-0\"\n                      animate={{\n                        rotate: active && sort?.direction === \"desc\" ? 180 : 0,\n                        opacity: active ? 1 : 0.35,\n                      }}\n                      transition={\n                        reduce\n                          ? { duration: 0 }\n                          : { duration: 0.18, ease: EASE_OUT }\n                      }\n                    >\n                      <ChevronUp className=\"h-3.5 w-3.5\" />\n                    </motion.span>\n                  </button>\n                ) : onColumnRename ? (\n                  <input\n                    value={\n                      typeof column.header === \"string\" ? column.header : \"\"\n                    }\n                    aria-label={`Rename ${column.key} column`}\n                    size={1}\n                    onChange={(e) =>\n                      onColumnRename(column.key, e.target.value)\n                    }\n                    className={cn(\n                      \"min-w-0 flex-1 truncate appearance-none rounded-md border-0 bg-transparent px-4 font-medium text-muted-foreground outline-none transition-colors focus:bg-muted focus:text-foreground\",\n                      alignText(column.align),\n                    )}\n                  />\n                ) : (\n                  <span\n                    className={cn(\n                      \"min-w-0 flex-1 truncate px-4\",\n                      alignText(column.align),\n                    )}\n                  >\n                    {column.header}\n                  </span>\n                )}\n              </motion.div>\n              {resizable ? (\n                <button\n                  type=\"button\"\n                  aria-label={`Resize ${column.key} column`}\n                  tabIndex={-1}\n                  onPointerDown={(e) => onResizeStart(column.key, e)}\n                  onPointerMove={onResizeMove}\n                  onPointerUp={onResizeEnd}\n                  className=\"absolute top-0 right-0 h-full w-1.5 cursor-col-resize touch-none bg-transparent transition-colors hover:bg-primary/40\"\n                />\n              ) : null}\n            </th>\n          );\n        })}\n        <th\n          aria-hidden\n          className=\"sticky top-0 z-10 border-border border-b bg-muted\"\n        />\n      </tr>\n    </thead>\n    </>\n  );\n}\n"},{"path":"components/motion/table/types.ts","type":"util","content":"import type { ReactNode } from \"react\";\n\nexport type SortDirection = \"asc\" | \"desc\";\n\nexport type SortState = {\n  key: string;\n  direction: SortDirection;\n};\n\nexport type TableColumn<T> = {\n  /** Stable key; also the default object property read for the cell + sort value. */\n  key: string;\n  /** Header content. */\n  header: ReactNode;\n  /** Allow clicking the header to sort by this column. */\n  sortable?: boolean;\n  /** Cell text alignment. */\n  align?: \"left\" | \"center\" | \"right\";\n  /** Column width as a CSS length, e.g. \"160px\" or \"20%\". Omit to share remaining space equally. */\n  width?: string;\n  /** Custom cell renderer. Falls back to `row[key]`. */\n  cell?: (row: T) => ReactNode;\n  /** Render an inline text input for this column's cells (ignored when `cell` is set). */\n  editable?: boolean;\n  /** Value used for sorting. Falls back to `row[key]`. */\n  sortValue?: (row: T) => string | number;\n};\n\nexport type InsertPosition = \"before\" | \"after\";\n\nexport interface TableProps<T> {\n  data: T[];\n  columns: TableColumn<T>[];\n  /** Stable id per row, required for correct selection across sorts. Defaults to row index. */\n  getRowId?: (row: T, index: number) => string;\n  /** Render a leading checkbox column with select-all in the header. */\n  selectable?: boolean;\n  selectedRowIds?: string[];\n  defaultSelectedRowIds?: string[];\n  onSelectionChange?: (ids: string[]) => void;\n  sort?: SortState | null;\n  defaultSort?: SortState | null;\n  onSortChange?: (sort: SortState | null) => void;\n  /** Allow dragging the right edge of a header to resize that column. */\n  resizable?: boolean;\n  /** Minimum column width in px when resizing. */\n  minColumnWidth?: number;\n  onColumnResize?: (key: string, width: number) => void;\n  /** Allow dragging a header grip to reorder columns. */\n  reorderable?: boolean;\n  onColumnOrderChange?: (keys: string[]) => void;\n  /** Called when an `editable` cell changes. */\n  onCellEdit?: (rowId: string, columnKey: string, value: string) => void;\n  /** When set, non-sortable headers become editable inputs for the column name. */\n  onColumnRename?: (columnKey: string, value: string) => void;\n  /** Enables the row menu (Insert before / after). Receives the target index. */\n  onInsertRow?: (index: number, position: InsertPosition) => void;\n  /** Enables Delete in the row menu. */\n  onDeleteRow?: (rowId: string, index: number) => void;\n  /** Enables the column menu (Insert before / after). Receives the target column index. */\n  onInsertColumn?: (index: number, position: InsertPosition) => void;\n  /** Enables Delete in the column menu. */\n  onDeleteColumn?: (columnKey: string, index: number) => void;\n  /** Fixed row height in px — required for virtualization. */\n  rowHeight?: number;\n  /** Scroll viewport height in px. */\n  height?: number;\n  /** Rows rendered above/below the viewport. */\n  overscan?: number;\n  /** Fires when the viewport scrolls near the bottom — load the next page. */\n  onEndReached?: () => void;\n  /** Currently fetching — shows skeleton rows and pauses `onEndReached`. */\n  loading?: boolean;\n  /** How many skeleton rows to show while loading more (default 3). */\n  skeletonRows?: number;\n  emptyState?: ReactNode;\n  className?: string;\n}\n\n/** A data row paired with its stable id. */\nexport type TableRow<T> = { row: T; id: string };\n\n/** Ref map from column key to its header cell, shared across the resize/reorder hooks. */\nexport type HeaderCellRefs = {\n  current: Record<string, HTMLTableCellElement | null>;\n};\n"},{"path":"components/motion/table/use-column-reorder.ts","type":"util","content":"import {\n  type PointerEvent as ReactPointerEvent,\n  useCallback,\n  useMemo,\n  useState,\n} from \"react\";\nimport type { HeaderCellRefs, TableColumn } from \"./types\";\n\nexport function useColumnReorder<T>({\n  columns,\n  thRefs,\n  onColumnOrderChange,\n}: {\n  columns: TableColumn<T>[];\n  thRefs: HeaderCellRefs;\n  onColumnOrderChange?: (keys: string[]) => void;\n}) {\n  const [order, setOrder] = useState<string[]>(() =>\n    columns.map((c) => c.key),\n  );\n  const [dragKey, setDragKey] = useState<string | null>(null);\n  const [dropIndex, setDropIndex] = useState<number | null>(null);\n\n  // Apply the current order, tolerating columns added/removed at runtime. New\n  // columns are placed at their position in `columns` (after their left\n  // neighbor), not appended — so an inserted column lands where it was added.\n  const orderedColumns = useMemo(() => {\n    const byKey = new Map(columns.map((c) => [c.key, c]));\n    const resultKeys = order.filter((k) => byKey.has(k));\n    const present = new Set(resultKeys);\n    columns.forEach((column, i) => {\n      if (present.has(column.key)) return;\n      let at = resultKeys.length;\n      if (i === 0) {\n        at = 0;\n      } else {\n        const idx = resultKeys.indexOf(columns[i - 1].key);\n        at = idx === -1 ? i : idx + 1;\n      }\n      resultKeys.splice(at, 0, column.key);\n      present.add(column.key);\n    });\n    return resultKeys\n      .map((k) => byKey.get(k))\n      .filter((c): c is TableColumn<T> => c !== undefined);\n  }, [order, columns]);\n\n  const dropIndexFor = useCallback(\n    (clientX: number) => {\n      for (let i = 0; i < orderedColumns.length; i++) {\n        const rect =\n          thRefs.current[orderedColumns[i].key]?.getBoundingClientRect();\n        if (rect && clientX < rect.left + rect.width / 2) return i;\n      }\n      return orderedColumns.length;\n    },\n    [orderedColumns, thRefs],\n  );\n\n  const startReorder = useCallback((key: string, e: ReactPointerEvent) => {\n    e.preventDefault();\n    e.stopPropagation();\n    setDragKey(key);\n    e.currentTarget.setPointerCapture(e.pointerId);\n  }, []);\n\n  const moveReorder = useCallback(\n    (e: ReactPointerEvent) => {\n      if (!dragKey) return;\n      setDropIndex(dropIndexFor(e.clientX));\n    },\n    [dragKey, dropIndexFor],\n  );\n\n  const endReorder = useCallback(\n    (e: ReactPointerEvent) => {\n      if (e.currentTarget.hasPointerCapture(e.pointerId)) {\n        e.currentTarget.releasePointerCapture(e.pointerId);\n      }\n      if (dragKey && dropIndex !== null) {\n        const keys = orderedColumns.map((c) => c.key);\n        const from = keys.indexOf(dragKey);\n        if (from !== -1) {\n          const without = keys.filter((_, i) => i !== from);\n          let to = dropIndex;\n          if (from < to) to--;\n          without.splice(to, 0, dragKey);\n          setOrder(without);\n          onColumnOrderChange?.(without);\n        }\n      }\n      setDragKey(null);\n      setDropIndex(null);\n    },\n    [dragKey, dropIndex, orderedColumns, onColumnOrderChange],\n  );\n\n  return {\n    orderedColumns,\n    dragKey,\n    dropIndex,\n    startReorder,\n    moveReorder,\n    endReorder,\n  };\n}\n"},{"path":"components/motion/table/use-column-resize.ts","type":"util","content":"import {\n  type PointerEvent as ReactPointerEvent,\n  useCallback,\n  useRef,\n  useState,\n} from \"react\";\nimport type { HeaderCellRefs, TableColumn } from \"./types\";\n\nexport function useColumnResize<T>({\n  orderedColumns,\n  thRefs,\n  minColumnWidth,\n  onColumnResize,\n}: {\n  orderedColumns: TableColumn<T>[];\n  thRefs: HeaderCellRefs;\n  minColumnWidth: number;\n  onColumnResize?: (key: string, width: number) => void;\n}) {\n  const resizeRef = useRef<{\n    key: string;\n    startX: number;\n    startWidth: number;\n  } | null>(null);\n  const [widths, setWidths] = useState<Record<string, number>>({});\n\n  const startResize = useCallback(\n    (key: string, e: ReactPointerEvent) => {\n      e.preventDefault();\n      e.stopPropagation();\n      // Freeze every column to its current pixel width so resizing one only\n      // moves the trailing spacer, never the other columns.\n      setWidths((prev) => {\n        const snapshot = { ...prev };\n        for (const column of orderedColumns) {\n          if (snapshot[column.key] == null) {\n            const measured = thRefs.current[column.key]?.getBoundingClientRect()\n              .width;\n            snapshot[column.key] = measured\n              ? Math.round(measured)\n              : minColumnWidth;\n          }\n        }\n        resizeRef.current = {\n          key,\n          startX: e.clientX,\n          startWidth: snapshot[key],\n        };\n        return snapshot;\n      });\n      e.currentTarget.setPointerCapture(e.pointerId);\n    },\n    [minColumnWidth, orderedColumns, thRefs],\n  );\n\n  const moveResize = useCallback(\n    (e: ReactPointerEvent) => {\n      const state = resizeRef.current;\n      if (!state) return;\n      const width = Math.max(\n        minColumnWidth,\n        state.startWidth + (e.clientX - state.startX),\n      );\n      setWidths((prev) => ({ ...prev, [state.key]: width }));\n    },\n    [minColumnWidth],\n  );\n\n  const endResize = useCallback(\n    (e: ReactPointerEvent) => {\n      const state = resizeRef.current;\n      resizeRef.current = null;\n      if (e.currentTarget.hasPointerCapture(e.pointerId)) {\n        e.currentTarget.releasePointerCapture(e.pointerId);\n      }\n      if (state) {\n        onColumnResize?.(state.key, widths[state.key] ?? state.startWidth);\n      }\n    },\n    [onColumnResize, widths],\n  );\n\n  return { widths, startResize, moveResize, endResize };\n}\n"},{"path":"components/motion/table/use-column-sort.ts","type":"util","content":"import { useCallback, useMemo, useState } from \"react\";\nimport type { SortState, TableColumn, TableRow } from \"./types\";\nimport { readSortValue } from \"./utils\";\n\nexport function useColumnSort<T>({\n  rows,\n  columns,\n  sort: sortProp,\n  defaultSort = null,\n  onSortChange,\n}: {\n  rows: TableRow<T>[];\n  columns: TableColumn<T>[];\n  sort?: SortState | null;\n  defaultSort?: SortState | null;\n  onSortChange?: (sort: SortState | null) => void;\n}) {\n  const [internalSort, setInternalSort] = useState<SortState | null>(\n    defaultSort,\n  );\n  const sort = sortProp !== undefined ? sortProp : internalSort;\n\n  const commit = useCallback(\n    (next: SortState | null) => {\n      if (sortProp === undefined) setInternalSort(next);\n      onSortChange?.(next);\n    },\n    [sortProp, onSortChange],\n  );\n\n  const toggleSort = useCallback(\n    (key: string) => {\n      if (!sort || sort.key !== key) {\n        commit({ key, direction: \"asc\" });\n      } else if (sort.direction === \"asc\") {\n        commit({ key, direction: \"desc\" });\n      } else {\n        commit(null);\n      }\n    },\n    [sort, commit],\n  );\n\n  const sortedRows = useMemo(() => {\n    if (!sort) return rows;\n    const column = columns.find((c) => c.key === sort.key);\n    if (!column) return rows;\n    const copy = [...rows];\n    copy.sort((a, b) => {\n      const av = readSortValue(a.row, column);\n      const bv = readSortValue(b.row, column);\n      let cmp: number;\n      if (typeof av === \"number\" && typeof bv === \"number\") {\n        cmp = av - bv;\n      } else {\n        cmp = String(av).localeCompare(String(bv));\n      }\n      return sort.direction === \"asc\" ? cmp : -cmp;\n    });\n    return copy;\n  }, [rows, sort, columns]);\n\n  return { sort, sortedRows, toggleSort };\n}\n"},{"path":"components/motion/table/use-row-selection.ts","type":"util","content":"import { useCallback, useMemo, useState } from \"react\";\nimport type { TableRow } from \"./types\";\n\nexport function useRowSelection<T>({\n  sortedRows,\n  selectedRowIds,\n  defaultSelectedRowIds,\n  onSelectionChange,\n}: {\n  sortedRows: TableRow<T>[];\n  selectedRowIds?: string[];\n  defaultSelectedRowIds?: string[];\n  onSelectionChange?: (ids: string[]) => void;\n}) {\n  const [internalSelected, setInternalSelected] = useState<Set<string>>(\n    () => new Set(defaultSelectedRowIds),\n  );\n  const selected = useMemo(\n    () =>\n      selectedRowIds !== undefined\n        ? new Set(selectedRowIds)\n        : internalSelected,\n    [selectedRowIds, internalSelected],\n  );\n\n  const commit = useCallback(\n    (next: Set<string>) => {\n      if (selectedRowIds === undefined) setInternalSelected(next);\n      onSelectionChange?.([...next]);\n    },\n    [selectedRowIds, onSelectionChange],\n  );\n\n  const allSelected =\n    sortedRows.length > 0 && sortedRows.every((r) => selected.has(r.id));\n  const someSelected = sortedRows.some((r) => selected.has(r.id));\n\n  const toggleAll = useCallback(() => {\n    const next = new Set(selected);\n    if (allSelected) {\n      for (const r of sortedRows) next.delete(r.id);\n    } else {\n      for (const r of sortedRows) next.add(r.id);\n    }\n    commit(next);\n  }, [allSelected, sortedRows, selected, commit]);\n\n  const toggleRow = useCallback(\n    (id: string) => {\n      const next = new Set(selected);\n      if (next.has(id)) next.delete(id);\n      else next.add(id);\n      commit(next);\n    },\n    [selected, commit],\n  );\n\n  return { selected, allSelected, someSelected, toggleAll, toggleRow };\n}\n"},{"path":"components/motion/table/utils.ts","type":"util","content":"import type { ReactNode } from \"react\";\nimport type { TableColumn } from \"./types\";\n\nexport const CHECKBOX_PX = 48;\nexport const CHECKBOX_WIDTH = `${CHECKBOX_PX}px`;\n\n/** Highlights the top edge of the active column's header cell. */\nexport const COLUMN_ACTIVE_SHADOW = \"inset 0 1px 0 var(--color-primary)\";\n\nexport function alignFlex(align: TableColumn<unknown>[\"align\"]) {\n  if (align === \"right\") return \"justify-end\";\n  if (align === \"center\") return \"justify-center\";\n  return \"justify-start\";\n}\n\nexport function alignText(align: TableColumn<unknown>[\"align\"]) {\n  if (align === \"right\") return \"text-right\";\n  if (align === \"center\") return \"text-center\";\n  return \"text-left\";\n}\n\nexport function readCell<T>(row: T, column: TableColumn<T>): ReactNode {\n  if (column.cell) return column.cell(row);\n  return (row as Record<string, ReactNode>)[column.key];\n}\n\nexport function readSortValue<T>(\n  row: T,\n  column: TableColumn<T>,\n): string | number {\n  if (column.sortValue) return column.sortValue(row);\n  return (row as Record<string, string | number>)[column.key];\n}\n"},{"path":"components/motion/checkbox.tsx","type":"util","content":"\"use client\";\n\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useId } from \"react\";\nimport { EASE_OUT, SPRING_PRESS } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nconst CHECK_PATH = \"M5 13l4 4L19 7\";\nconst INDETERMINATE_PATH = \"M6 12h12\";\n\nexport interface CheckboxProps {\n  checked: boolean;\n  onCheckedChange: (checked: boolean) => void;\n  disabled?: boolean;\n  indeterminate?: boolean;\n  label?: string;\n  className?: string;\n  id?: string;\n  \"aria-label\"?: string;\n}\n\nexport function Checkbox({\n  checked,\n  onCheckedChange,\n  disabled,\n  indeterminate,\n  label,\n  className,\n  id: idProp,\n  \"aria-label\": ariaLabel,\n}: CheckboxProps) {\n  const autoId = useId();\n  const id = idProp ?? autoId;\n  const reduce = useReducedMotion();\n  const showMark = checked || indeterminate;\n  const path = indeterminate ? INDETERMINATE_PATH : CHECK_PATH;\n\n  return (\n    <label\n      htmlFor={id}\n      className={cn(\n        \"inline-flex items-center gap-3\",\n        disabled ? \"cursor-not-allowed\" : \"cursor-pointer\",\n        className,\n      )}\n    >\n      <motion.button\n        id={id}\n        type=\"button\"\n        role=\"checkbox\"\n        aria-checked={indeterminate ? \"mixed\" : checked}\n        aria-label={ariaLabel}\n        disabled={disabled}\n        onClick={() => !disabled && onCheckedChange(!checked)}\n        whileTap={reduce || disabled ? undefined : { scale: 0.92 }}\n        transition={SPRING_PRESS}\n        data-state={\n          checked ? \"checked\" : indeterminate ? \"indeterminate\" : \"unchecked\"\n        }\n        className={cn(\n          \"inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-md border-2 outline-none transition-colors duration-200\",\n          \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\",\n          \"disabled:cursor-not-allowed disabled:opacity-60\",\n          showMark\n            ? \"border-primary bg-primary text-primary-foreground\"\n            : \"border-muted-foreground/50 bg-background hover:border-muted-foreground\",\n        )}\n      >\n        <AnimatePresence initial={false}>\n          {showMark ? (\n            <motion.svg\n              key={indeterminate ? \"indeterminate\" : \"checked\"}\n              width=\"12\"\n              height=\"12\"\n              viewBox=\"0 0 24 24\"\n              fill=\"none\"\n              stroke=\"currentColor\"\n              strokeWidth={3}\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n              initial={reduce ? { opacity: 1 } : { opacity: 0, scale: 0.5 }}\n              animate={reduce ? { opacity: 1 } : { opacity: 1, scale: 1 }}\n              exit={\n                reduce\n                  ? { opacity: 0 }\n                  : { opacity: 0, scale: 0.5, filter: \"blur(4px)\" }\n              }\n              transition={\n                reduce ? { duration: 0 } : { duration: 0.16, ease: EASE_OUT }\n              }\n              aria-hidden\n            >\n              <title>{indeterminate ? \"Partially selected\" : \"Selected\"}</title>\n              <motion.path\n                d={path}\n                initial={reduce ? { pathLength: 1 } : { pathLength: 0 }}\n                animate={{ pathLength: 1 }}\n                transition={\n                  reduce\n                    ? { duration: 0 }\n                    : {\n                        duration: indeterminate ? 0.2 : 0.3,\n                        ease: EASE_OUT,\n                        delay: 0.04,\n                      }\n                }\n              />\n            </motion.svg>\n          ) : null}\n        </AnimatePresence>\n      </motion.button>\n      {label ? (\n        <span className={cn(\"select-none text-sm text-foreground\", disabled && \"opacity-60\")}>\n          {label}\n        </span>\n      ) : null}\n    </label>\n  );\n}\n"},{"path":"lib/utils.ts","type":"util","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"},{"path":"components/motion/table/table-menu.tsx","type":"util","content":"\"use client\";\n\nimport { motion, useReducedMotion } from \"motion/react\";\nimport { type ReactNode, useEffect, useRef, useState } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { SPRING_PANEL } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport type TableMenuItem = {\n  label: string;\n  icon?: ReactNode;\n  onSelect: () => void;\n  destructive?: boolean;\n};\n\nconst MENU_WIDTH = 188;\n\nexport function TableMenu({\n  items,\n  ariaLabel,\n  trigger,\n  triggerClassName,\n}: {\n  items: TableMenuItem[];\n  ariaLabel: string;\n  trigger: ReactNode;\n  triggerClassName?: string;\n}) {\n  const reduce = useReducedMotion();\n  const triggerRef = useRef<HTMLButtonElement>(null);\n  const [coords, setCoords] = useState<{ top: number; left: number } | null>(\n    null,\n  );\n  const open = coords !== null;\n\n  useEffect(() => {\n    if (!open) return;\n    const close = () => setCoords(null);\n    const onKey = (e: KeyboardEvent) => {\n      if (e.key === \"Escape\") close();\n    };\n    // Close on any scroll (the trigger moves) or resize; fixed coords go stale.\n    window.addEventListener(\"scroll\", close, true);\n    window.addEventListener(\"resize\", close);\n    window.addEventListener(\"keydown\", onKey);\n    return () => {\n      window.removeEventListener(\"scroll\", close, true);\n      window.removeEventListener(\"resize\", close);\n      window.removeEventListener(\"keydown\", onKey);\n    };\n  }, [open]);\n\n  const toggle = () => {\n    if (open) {\n      setCoords(null);\n      return;\n    }\n    const el = triggerRef.current;\n    if (!el) return;\n    const r = el.getBoundingClientRect();\n    setCoords({\n      top: r.bottom + 4,\n      left: Math.max(8, r.right - MENU_WIDTH),\n    });\n  };\n\n  return (\n    <>\n      <button\n        ref={triggerRef}\n        type=\"button\"\n        aria-label={ariaLabel}\n        aria-haspopup=\"menu\"\n        aria-expanded={open}\n        onClick={(e) => {\n          e.stopPropagation();\n          toggle();\n        }}\n        className={triggerClassName}\n      >\n        {trigger}\n      </button>\n      {open && typeof document !== \"undefined\"\n        ? createPortal(\n            <>\n              <div\n                className=\"fixed inset-0 z-40\"\n                onPointerDown={() => setCoords(null)}\n              />\n              <motion.div\n                role=\"menu\"\n                className=\"fixed z-50 overflow-hidden rounded-xl border border-border bg-background p-1 shadow-xl\"\n                style={{ top: coords.top, left: coords.left, width: MENU_WIDTH }}\n                initial={\n                  reduce ? { opacity: 0 } : { opacity: 0, scale: 0.96, y: -4 }\n                }\n                animate={\n                  reduce ? { opacity: 1 } : { opacity: 1, scale: 1, y: 0 }\n                }\n                transition={reduce ? { duration: 0 } : SPRING_PANEL}\n              >\n                {items.map((item) => (\n                  <button\n                    key={item.label}\n                    type=\"button\"\n                    role=\"menuitem\"\n                    onClick={() => {\n                      setCoords(null);\n                      item.onSelect();\n                    }}\n                    className={cn(\n                      \"flex w-full items-center gap-2.5 rounded-lg px-2.5 py-1.5 text-left text-sm transition-colors [&_svg]:h-4 [&_svg]:w-4\",\n                      item.destructive\n                        ? \"text-rose-500 hover:bg-rose-500/10\"\n                        : \"text-foreground hover:bg-muted\",\n                    )}\n                  >\n                    {item.icon}\n                    {item.label}\n                  </button>\n                ))}\n              </motion.div>\n            </>,\n            document.body,\n          )\n        : null}\n    </>\n  );\n}\n"},{"path":"lib/ease.ts","type":"util","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n"},{"path":"components/previews/motion/table.preview.tsx","type":"preview","content":"\"use client\";\n\nimport { useMemo, useState } from \"react\";\nimport { Table, type TableColumn } from \"@/components/motion/table\";\nimport { cn } from \"@/lib/utils\";\n\ntype Person = {\n  id: string;\n  name: string;\n  email: string;\n  role: string;\n  status: \"active\" | \"invited\" | \"suspended\";\n  mrr: number;\n};\n\nconst FIRST = [\n  \"Ava\",\n  \"Leo\",\n  \"Mia\",\n  \"Kai\",\n  \"Zoe\",\n  \"Eli\",\n  \"Noa\",\n  \"Ren\",\n  \"Ivy\",\n  \"Jude\",\n];\nconst LAST = [\n  \"Cole\",\n  \"Frost\",\n  \"Vale\",\n  \"Reyes\",\n  \"Okafor\",\n  \"Sato\",\n  \"Lund\",\n  \"Marsh\",\n  \"Bose\",\n  \"Quinn\",\n];\nconst ROLES = [\"Owner\", \"Admin\", \"Member\", \"Viewer\"];\nconst STATUSES: Person[\"status\"][] = [\"active\", \"invited\", \"suspended\"];\n\n// Deterministic so SSR and client render the same rows (no hydration drift).\nfunction buildPeople(count: number): Person[] {\n  const out: Person[] = [];\n  for (let i = 0; i < count; i++) {\n    const first = FIRST[i % FIRST.length];\n    const last = LAST[(i * 7) % LAST.length];\n    out.push({\n      id: String(i),\n      name: `${first} ${last}`,\n      email: `${first.toLowerCase()}.${last.toLowerCase()}${i}@beui.dev`,\n      role: ROLES[(i * 3) % ROLES.length],\n      status: STATUSES[(i * 5) % STATUSES.length],\n      mrr: 12 + ((i * 37) % 488),\n    });\n  }\n  return out;\n}\n\nconst STATUS_STYLES: Record<Person[\"status\"], string> = {\n  active: \"bg-emerald-500/10 text-emerald-600 dark:text-emerald-400\",\n  invited: \"bg-amber-500/10 text-amber-600 dark:text-amber-400\",\n  suspended: \"bg-rose-500/10 text-rose-600 dark:text-rose-400\",\n};\n\nfunction StatusBadge({ status }: { status: Person[\"status\"] }) {\n  return (\n    <span\n      className={cn(\n        \"rounded-full px-2 py-0.5 font-medium text-xs capitalize\",\n        STATUS_STYLES[status],\n      )}\n    >\n      {status}\n    </span>\n  );\n}\n\nexport function TablePreview() {\n  const data = useMemo(() => buildPeople(10_000), []);\n  const [selected, setSelected] = useState<string[]>([]);\n\n  const columns = useMemo<TableColumn<Person>[]>(\n    () => [\n      {\n        key: \"name\",\n        header: \"Name\",\n        sortable: true,\n        width: \"1.4fr\",\n        cell: (row) => <span className=\"font-medium\">{row.name}</span>,\n      },\n      { key: \"email\", header: \"Email\", width: \"1.8fr\" },\n      { key: \"role\", header: \"Role\", sortable: true, width: \"120px\" },\n      {\n        key: \"status\",\n        header: \"Status\",\n        width: \"130px\",\n        cell: (row) => <StatusBadge status={row.status} />,\n      },\n      {\n        key: \"mrr\",\n        header: \"MRR\",\n        sortable: true,\n        align: \"right\",\n        width: \"110px\",\n        cell: (row) => (\n          <span className=\"tabular-nums\">${row.mrr.toLocaleString()}</span>\n        ),\n      },\n    ],\n    [],\n  );\n\n  return (\n    <div className=\"flex w-full justify-center p-4\">\n      <div className=\"flex w-full flex-col gap-2\">\n        <div className=\"flex items-center justify-between px-1 text-muted-foreground text-xs\">\n          <span>{data.length.toLocaleString()} rows</span>\n          {selected.length > 0 ? (\n            <span>{selected.length.toLocaleString()} selected</span>\n          ) : null}\n        </div>\n        <Table\n          data={data}\n          columns={columns}\n          selectable\n          resizable\n          reorderable\n          selectedRowIds={selected}\n          onSelectionChange={setSelected}\n          defaultSort={{ key: \"mrr\", direction: \"desc\" }}\n          height={420}\n          rowHeight={52}\n          className=\"rounded-2xl\"\n        />\n      </div>\n    </div>\n  );\n}\n"}]}