@polycyphers/spreadsheet
v1.0.18
Published
High-performance virtual-scrolled React spreadsheet with Excel I/O, formula engine, and plugin system
Maintainers
Readme
@polycyphers/spreadsheet
High-performance virtual-scrolled React spreadsheet with Excel I/O, a formula engine, and a plugin system.
Commercial. Production use requires a license — unlicensed instances render a visible watermark. A free 30-day trial is available, email-gated, no credit card. Details + pricing at packages.polycyphers.com/spreadsheet.
Highlights
- Virtualized — handles 100k+ rows without dropping frames
- Excel I/O — import/export XLSX with styles, formulas, merges
- Formula engine — 100+ built-in functions, custom registrations, cross-sheet refs
- Typed cells — text, number, currency, percentage, date, select, checkbox, formula
- Sort / filter / pin / merge — all standard grid affordances
- Clipboard + undo/redo — tab-delimited, batched history
- Plugin system —
CorePlugin<T>extension points for custom columns, validation, toolbars - React 19 + TypeScript-first, DaisyUI-compatible styles, tree-shakable
Install
npm install @polycyphers/spreadsheetSetup
Import the stylesheet once in your app entry (e.g. main.tsx):
import "@polycyphers/spreadsheet/css";Peer dependencies — your app must have:
react >= 18react-dom >= 18exceljs >= 4.4.0(optional — required only for Excel I/O)
Styling requirements (not peer deps, but expected by the CSS):
- Tailwind CSS + DaisyUI — the stylesheet references DaisyUI theme CSS variables
Quick Start
import { Spreadsheet, type SpreadsheetColumn } from "@polycyphers/spreadsheet";
interface Employee {
id: number;
name: string;
department: string;
}
const columns: SpreadsheetColumn<Employee>[] = [
{ key: "id", label: "ID", width: 60 },
{ key: "name", label: "Name", width: 200 },
{ key: "department", label: "Department", width: 150 },
];
function MyPage() {
const [data, setData] = useState<Employee[]>([]);
return (
<Spreadsheet
data={data}
columns={columns}
persistKey="my-spreadsheet"
/>
);
}Features
| Feature | Description |
|---------|-------------|
| Virtual Scrolling | Renders only visible rows — handles 100k+ rows smoothly |
| Column Resize | Drag-to-resize with localStorage persistence, batch resize for multi-selected columns |
| Row Height Resize | Drag row handles or double-click auto-fit; batch resize selected rows |
| Sorting | Click column headers, async transitions |
| Filtering | Global search + per-column dropdown filters |
| Cell Selection | Single cell, row, column, and range selection |
| Inline Editing | Text, number, select, checkbox, date, and custom editors |
| Excel Import (Full Fidelity) | Import .xlsx preserving fonts, colors, borders, alignment, number formats, merged cells, freeze panes, column widths, row heights, multi-sheet workbooks, and formulas |
| Excel Export | Export to .xlsx with styling (via ExcelJS) |
| CSV Export | RFC 4180-compliant CSV export |
| Formula Engine | 70+ built-in functions, dependency graph, circular reference detection, live evaluation |
| Formula Bar | View/edit active cell formula with commit button |
| Multi-Sheet Tabs | Automatic sheet tabs from imported workbooks; configurable prop-based tabs |
| Cell Merging | Merge cells across columns with O(1) lookup |
| Freeze Panes | Freeze left columns and top rows (from props or auto-applied from Excel import) |
| Copy / Paste | Ctrl+C/X/V with system clipboard |
| Undo / Redo | 100-step history, Ctrl+Z / Ctrl+Shift+Z |
| Status Bar | Sum, Avg, Count, Min, Max for multi-cell selections |
| Plugin System | Extensible lifecycle hooks for custom behavior |
| Context Menu | Right-click menus with visibility/disabled controls |
| Session Persistence | Column widths, sort state saved to storage |
Excel Import — Full Fidelity
The spreadsheet can display an imported Excel (or Apple Numbers) file exactly as it appears in the original, including all formatting.
Basic import (matched columns)
const ref = useRef<SpreadsheetRef<MyRow>>(null);
// Trigger from a file input
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) ref.current?.importFromExcel(file);
};Free-form import (auto-generates columns from Excel headers)
When you don't know the column structure ahead of time, use freeFormImport. The spreadsheet will auto-generate column definitions from the Excel header row and display the file exactly as-is.
<Spreadsheet
data={data}
columns={columns}
excelConfig={{ freeFormImport: true }}
ref={ref}
/>What is extracted
| Excel property | Extracted |
|---|---|
| Cell values | ✅ |
| Formulas (=SUM(A1:B5), etc.) | ✅ evaluated live by formula engine |
| Font (bold, italic, underline, strikethrough, size, color, family) | ✅ |
| Background fill color | ✅ |
| Text alignment (horizontal + vertical) | ✅ |
| Word wrap | ✅ |
| Borders (top, bottom, left, right — style + color) | ✅ |
| Number formats (%, currency, decimals) | ✅ displayed + applied |
| Merged cells | ✅ |
| Freeze panes | ✅ |
| Column widths | ✅ |
| Row heights | ✅ |
| All sheets (multi-sheet workbook) | ✅ tabs rendered automatically |
| Rich text | ✅ (text content extracted) |
| Dates | ✅ |
Types for custom import processing
import type {
CellStyle,
CellStyleMap,
ExcelImportMeta,
ExcelSheetData,
} from "@polycyphers/spreadsheet";Exports
Component
import { Spreadsheet } from "@polycyphers/spreadsheet";Sub-Components
import { SheetTabs, LongTextDialog } from "@polycyphers/spreadsheet";Hooks
import {
useVirtualScroll,
useColumnResize,
useRowHeightResize,
useSelection,
useSorting,
useFiltering,
useExcelIO,
useFormulaEngine,
usePinning,
useMergedCells,
useStatusBar,
useClipboard,
useUndoRedo,
useDynamicColumns,
useOverflowDetection,
} from "@polycyphers/spreadsheet";Plugin Utilities
import { usePlugins, createPlugin, composePlugins } from "@polycyphers/spreadsheet";Feature Hooks
import {
useHourValidation,
useCostCodes,
useInlineRowCreation,
useContextMenu,
} from "@polycyphers/spreadsheet";Types
import type {
// Core
SpreadsheetColumn,
SpreadsheetProps,
SpreadsheetRef,
SpreadsheetMode, // "view" | "edit" | "create"
SpreadsheetVariant, // "default" | "compact" | "pointage" | "tasks"
SpreadsheetExcelConfig,
SpreadsheetFeatures,
CellEditorType, // "text" | "number" | "select" | "checkbox" | "date" | "custom"
// Selection & navigation
CellPosition,
SelectionRange,
SelectionMode, // "cell" | "row" | "column"
NavigationDirection, // "down" | "up" | "right" | "left"
PersistedSheetState,
// Sheet tabs
SheetTab,
// Excel import
CellBorder,
CellStyle,
CellStyleMap,
ExcelImportMeta,
ExcelSheetData,
// Feature configs
InlineRowCreationConfig,
RowHeightResizeConfig,
ContextMenuConfig,
ContextMenuItem,
CostCodeDialogConfig,
HourValidationConfig,
HourValidationError,
CalculatedFieldConfig,
DirtyTrackingConfig,
DynamicColumnConfig,
MultiRowSelectionConfig,
PositionSummaryConfig,
// Plugin system
SpreadsheetPlugin,
PluginContext,
CellInfo,
} from "@polycyphers/spreadsheet";Hook option/result types (UseVirtualScrollOptions, VirtualScrollResult, UseColumnResizeOptions, ColumnResizeResult, UseSelectionOptions, SelectionResult, UseSortingOptions, SortingResult, UseFilteringOptions, FilteringResult, UseExcelIOOptions, ExcelIOResult, UseClipboardOptions, ClipboardResult, UseUndoRedoOptions, UndoRedoResult, CellChange, UndoRedoEntry, UsePinningOptions, PinningResult, PinningConfig, UseMergedCellsOptions, MergedCellsResult, MergedCellRange, MergedCellInfo, UseStatusBarOptions, StatusBarResult, StatusBarStats, UseFormulaEngineOptions, FormulaEngineResult, UseRowHeightResizeOptions, RowHeightResizeResult, UseDynamicColumnsOptions, DynamicColumnsResult, UseHourValidationOptions, UseHourValidationResult, UseCostCodesOptions, UseCostCodesResult, UseInlineRowCreationOptions, UseInlineRowCreationResult, UseContextMenuOptions, UseContextMenuResult, UsePluginsOptions, UsePluginsResult, PointagePluginConfig, TasksPluginConfig) are all exported for advanced composition.
Formula Engine
import {
FormulaEngine, // standalone class
DependencyGraph, // cell dependency graph
Errors, FormulaError, isError,
builtinFunctions, functionCatalog,
colToNum, numToCol, cellId, parseCellId, adjustFormulaRefs,
} from "@polycyphers/spreadsheet";
import type {
CellValue,
CellMatrix,
CellData,
CellRef,
FormulaFunction,
FunctionInfo,
ASTNode,
} from "@polycyphers/spreadsheet";Editor sub-components
import {
CellTextEditor,
CellSelectEditor,
OverflowCellContent,
} from "@polycyphers/spreadsheet";
import type {
CellTextEditorProps,
CellSelectEditorProps,
OverflowCellContentProps,
} from "@polycyphers/spreadsheet";Utilities
import {
columnIndexToLetter,
getValue,
useOverflowDetection,
} from "@polycyphers/spreadsheet";Constants
import {
DEFAULT_ROW_HEIGHT, // 32
DEFAULT_OVERSCAN, // 8
DEFAULT_MIN_BUFFER_ROWS, // 8
DEFAULT_MIN_RENDER_ROWS, // 30
DEFAULT_COLUMN_MIN_WIDTH, // 60
DEFAULT_COLUMN_MAX_WIDTH, // 600
} from "@polycyphers/spreadsheet";License
import {
setLicense,
getLicenseStatus,
isLicensed,
KARAMENT_INTERNAL_KEY,
} from "@polycyphers/spreadsheet";
import type { LicenseStatus } from "@polycyphers/spreadsheet";Props Reference
Core
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| data | T[] | — | Row objects |
| columns | SpreadsheetColumn<T>[] | — | Column definitions |
| mode | "view" \| "edit" \| "create" | "view" | Interaction mode |
| editContextKey | string \| number \| null | — | Changing this flushes pending edits (e.g. switching record) |
| loading | boolean | false | Show loading state |
| emptyMessage | string | — | Message shown when data is empty |
| containerClassName | string | — | Extra class on the outer container |
| persistKey | string | — | localStorage/sessionStorage key for column widths, sort, filters |
| variant | "default" \| "compact" \| "pointage" \| "tasks" | "default" | Layout density variant |
Layout / Virtualization
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| rowHeight | number \| (row,i)=>number | 32 | Fixed or per-row height |
| maxHeight | number \| string | — | Scroll region max height |
| overscan | number | 8 | Extra rows rendered outside viewport |
| minBufferRows | number | 8 | Minimum buffered rows above/below viewport |
| minRenderRows | number | 30 | Minimum rendered rows (prevents underdraw) |
| pinning | {columns?,rows?} | — | Freeze left columns / top rows |
| mergedCells | [{row,col,rowspan,colspan}] | — | Merged cell ranges |
Slots
| Prop | Type | Description |
|------|------|-------------|
| toolbar | ReactNode | Custom formula bar toolbar |
| toolbarLeft | ReactNode | Content pinned to left of toolbar |
| formulaBarStart | ReactNode | Content before formula bar |
| hideFormulaBar | boolean | Hide the formula bar entirely |
| footer | ReactNode | Footer content below the grid |
| slotBelowHeader | ReactNode | Content injected below the column headers |
| summaryRow | (rows, ctx?) => ReactNode | Totals/summary row renderer |
| sheetTabs | SheetTab[] | Multi-sheet tabs |
| activeSheetTab | string | Active sheet tab key |
| onSheetTabChange | (key) => void | Tab change handler |
Callbacks
| Prop | Type | Description |
|------|------|-------------|
| onCellChange | (rowIndex, columnKey, value, row) => void | Single cell value changed |
| onRowChange | (rowIndex, row) => void | Any cell on a row changed |
| onDataChange | (rows) => void | Full dataset changed (paste, import, undo, etc.) |
| onRowClick | (row, index) => void | Row clicked |
| onRowDoubleClick | (row, index) => void | Row double-clicked |
| onRowContextMenu | (row, index, event) => void | Row right-clicked |
| onSelectionChange | (cell \| null, row?) => void | Active cell/row selection changed |
| onSearchChange | (search) => void | Global search text changed |
Row customization
| Prop | Type | Description |
|------|------|-------------|
| rowClassName | (row, i) => string \| undefined | Extra class per row |
| rowNumberRender | (row, i) => ReactNode | Custom row number cell |
| rowNumberTitle | (row, i) => string \| undefined | Tooltip for row number |
| isCellEditable | (row, column, i) => boolean | Per-cell edit guard (overrides column.editable) |
| actionsRender | (row, i) => ReactNode | Right-hand actions column renderer |
| actionsColumnWidth | number | Width of actions column |
| actionsColumnResizable | boolean | Allow actions column to be resized |
| getRowId | (row, i) => string \| number | Stable row identity (used by scrollToRowId) |
| scrollToRowId | string \| number \| null | Imperatively scroll to a row by id |
Feature flags
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| allowKeyboardNavigation | boolean | true | Arrow/Tab/Enter navigation |
| allowColumnResize | boolean | true | Drag-to-resize column widths |
| allowFilters | boolean | true | Column filter dropdowns |
| allowSorting | boolean | true | Column header sort |
| showStatusBar | boolean | false | Sum/Avg/Count/Min/Max bar |
Excel I/O
| Prop | Type | Description |
|------|------|-------------|
| excelConfig.fileName | string | Download filename for export |
| excelConfig.sheetName | string | Sheet name for export |
| excelConfig.freeFormImport | boolean | Auto-generate columns from Excel headers |
| excelConfig.exportBuilder | fn | Custom workbook builder |
| excelConfig.importParser | fn | Custom import parser (bypasses fidelity import) |
Extensibility
| Prop | Type | Description |
|------|------|-------------|
| plugins | SpreadsheetPlugin<T>[] | Lifecycle/event/render plugin array |
| features | SpreadsheetFeatures<T> | Feature config bag: inlineRowCreation, rowHeightResize, contextMenu, multiRowSelection, costCodeDialog, hourValidation, positionSummary, calculatedFields, dirtyTracking, dynamicColumns |
| aiId | string | AI integration id (for external assistants) |
SpreadsheetRef methods
const ref = useRef<SpreadsheetRef<T>>(null);
ref.current.exportToExcel() // download .xlsx
ref.current.importFromExcel(file) // load file with full fidelity
ref.current.exportToCSV() // download .csv
ref.current.undo()
ref.current.redo()
ref.current.getData() // get current rows
ref.current.resetColumnWidths()Plugin System
Plugins implement the SpreadsheetPlugin<T> interface. Every hook is optional — implement only what you need. Return true from an event hook to prevent the default behavior.
Plugin lifecycle & hook surface
| Hook | Signature | Purpose |
|------|-----------|---------|
| onMount | (context) => void \| (() => void) | Mount hook; return function runs on unmount |
| onUnmount | () => void | Explicit unmount callback |
| onDataChange | (data, prevData) => void | Fires when the row array reference changes |
| renderCellOverlay | (cell) => ReactNode | Overlay UI rendered above a cell |
| renderRowOverlay | (row, i) => ReactNode | Overlay rendered per row |
| renderToolbarExtension | (context) => ReactNode | Injects content into the formula bar toolbar |
| renderFooterExtension | (context) => ReactNode | Injects content into the footer area |
| onCellClick / onCellDoubleClick | (cell, event) => boolean \| void | Return true to prevent default |
| onCellChange | (cell, newValue, prevValue) => any | Return a value to transform the committed value |
| onRowClick | (row, i, event) => boolean \| void | Return true to prevent default |
| onKeyDown | (event, cell) => boolean \| void | Return true to prevent default |
| onContextMenu | (row, i, event) => boolean \| void | Return true to prevent default |
| transformData | (data) => data | Last-mile data transform (sort/filter/inject synthetic rows) |
| transformColumns | (columns) => columns | Last-mile column transform |
| validateCell | (cell, newValue) => { valid, message? } | Veto a cell edit |
PluginContext<T> provides { data, columns, mode, selectedCell, selectionRange, updateCellValue, setSelectedCell, getRow, getColumn, forceUpdate }.
Building a plugin
import { createPlugin, type SpreadsheetPlugin } from "@polycyphers/spreadsheet";
const uppercasePlugin: SpreadsheetPlugin<MyRow> = createPlugin({
name: "uppercase-names",
onCellChange: (cell, newValue) => {
if (cell.columnKey === "name" && typeof newValue === "string") {
return newValue.toUpperCase();
}
},
});
<Spreadsheet data={rows} columns={cols} plugins={[uppercasePlugin]} />Combine plugins with composePlugins(a, b, c).
Built-in plugins
| Plugin | Factory | Exported columns | Use case |
|--------|---------|------------------|----------|
| Pointage | createPointagePlugin(config) | POINTAGE_COLUMNS | Hour validation, bulk regular hours, position summary, salary type grouping |
| Tasks | createTasksPlugin(config) | TASK_COLUMNS | Inline row creation, context menu, row height resize, status/category dropdowns |
Config types: PointagePluginConfig, TasksPluginConfig.
Variants
The variant prop adjusts layout density:
| Variant | Description |
|---------|-------------|
| "default" | Standard spreadsheet |
| "compact" | Reduced row height and padding |
| "pointage" | Optimized for attendance/hours entry |
| "tasks" | Optimized for task management |
Formula Engine
Supports 70+ Excel-compatible functions. Introspect the full set at runtime via the exported functionCatalog.
Math: SUM, AVERAGE, COUNT, COUNTA, MIN, MAX, ROUND, ROUNDUP, ROUNDDOWN, CEILING, FLOOR, ABS, POWER, SQRT, MOD, INT, PRODUCT, PI, RAND, SIGN
Statistical: MEDIAN, MODE, STDEV, VAR, LARGE, SMALL, RANK, PERCENTILE
Logical: IF, IFS, AND, OR, NOT, IFERROR, SWITCH
Text: CONCATENATE, LEFT, RIGHT, MID, LEN, UPPER, LOWER, TRIM, SUBSTITUTE, FIND, SEARCH, TEXT, VALUE
Date: TODAY, NOW, DATE, YEAR, MONTH, DAY, DATEDIF, DAYS
Lookup: VLOOKUP, HLOOKUP, INDEX, MATCH
Financial: PMT, FV, PV, RATE
Information: ISBLANK, ISERROR, ISNUMBER, ISTEXT, SUMIF, COUNTIF, AVERAGEIF
Cell references support absolute ($A$1), relative (A1), and mixed forms.
Consumer integration
Required app setup
- Install the package —
npm install @polycyphers/spreadsheet. - Install peer deps in the app —
react >= 18,react-dom >= 18, andexceljs >= 4.4.0if Excel I/O is used. - Import the stylesheet once in the app entry:
// main.tsx import "@polycyphers/spreadsheet/css"; - Tailwind + DaisyUI must be configured in the host app — the stylesheet consumes DaisyUI theme CSS variables (e.g.
--b1,--bc,--p).
Vite alias — live source development
Use this pattern in a consumer's vite.config.ts to skip the build step while iterating on the package. Verified in PORTAL/Claims/vite.config.ts:
import path from "path";
const spreadsheetSourceRoot = path.resolve(
__dirname,
"../../karament-spreadsheet/src"
);
export default defineConfig({
resolve: {
dedupe: ["react", "react-dom", "@iconify/react"],
alias: [
{ find: "@polycyphers/spreadsheet/css", replacement: path.join(spreadsheetSourceRoot, "spreadsheet.css") },
{ find: "@polycyphers/spreadsheet", replacement: path.join(spreadsheetSourceRoot, "index.ts") },
],
},
optimizeDeps: {
exclude: ["@polycyphers/spreadsheet"],
},
});Key points:
| Setting | Why |
|---------|-----|
| resolve.dedupe: ["react", "react-dom", "@iconify/react"] | Prevents duplicate React copies (needed for hooks to work when package is resolved from source) |
| alias for @polycyphers/spreadsheet | Points import directly at src/index.ts — no rebuild to pick up edits |
| Separate alias for /css | CSS lives outside the JS entry; must be aliased explicitly |
| optimizeDeps.exclude | Keeps Vite from pre-bundling the package when using the alias |
Without the alias, consumers just use the published dist/ output — no special config required.
Adopter gotchas
- Do not import
@polycyphers/spreadsheet/cssinside the package's own JS — imports happen once in the app entry, never transitively from the library. Breaking this splits the CSS out of the single-file bundle. - React version mismatch is the #1 failure mode when using the Vite alias (duplicate copies →
Invalid hook call). Always addresolve.dedupe. - ExcelJS is optional but
peerDependenciesMetamarks it so — the app gets a bundler warning if it's missing and Excel I/O is called. Install it ifimportFromExcel/exportToExcelare used. persistKeywrites column widths, sort state, and filters to storage. Use a unique key per page; reusing a key across pages cross-contaminates state.editContextKeymust be bumped when the underlying record changes (e.g. navigating from record A to record B with the same page mounted). Without it, pending edits from A will flush into B.- Merged cells and pinning from Excel import override any
mergedCells/pinningprops on the component — imported files are treated as authoritative. - CSS is one file (
spreadsheet.css, not code-split). Size is predictable; import it only once. - Icons — the package ships its own
@iconify/react+@iconify/icons-lucideruntime dependencies. If the app also uses Iconify, add it toresolve.dedupe(see above) to avoid two registries. - Free-form Excel import re-generates
columnsfrom the file header row — any column props you pass in are discarded whenexcelConfig.freeFormImport: true.
Updating
When a new version is tagged:
npm install @polycyphers/spreadsheetDevelopment
git clone https://github.com/melejel2/karament-spreadsheet.git
cd karament-spreadsheet
npm install
npm run build # Build dist/
npm run dev # Watch mode
npm run type-check # TypeScript validationLicense
Commercial. See LICENSE.md and EULA.md. Trial + pricing at packages.polycyphers.com/spreadsheet.
Polycyphers © 2025-2026 | All rights reserved
