@lembryo/voxsheet
A React spreadsheet component that renders millions of rows smoothly with virtual scrolling. Excel-like selection, editing, autofill, clipboard, and column resizing, with host-controlled sort / filter / search.
▶ Open live demo Runs in your browser with in-browser data
Overview
VoxSheet uses DOM-based virtual scrolling to render only the cells currently in view, no
matter how many rows exist. Data fetching and domain state (sort / filter / search) are owned
by the host; the viewport, selection, edit buffer, and keyboard are owned by
the grid.
- Transport-agnostic — fetching is delegated to
fetchRows, so REST, GraphQL, or a local array all work. - TypeScript-first — the entire public API is typed.
- Peer-dependency model —
react/react-domare peer dependencies. - No external CSS framework — self-contained via
--vox-*variables andvox-classes.
Why voxsheet?
voxsheet's distinguishing trait is that it combines a free (MIT) Excel-like editing experience with a server-driven design from day one.
- Free Excel-like editing — autofill (fill handle), multi-range selection, copy & paste, and column resizing built in.
- Server-driven by design — sort, filter, search, and pagination are delegated to
your backend through
fetchRows. Even huge datasets fetch only the visible window. - React-native / DOM-based — hooks, controlled props, JSX cells. Unlike canvas grids, you extend, inspect, and handle accessibility with ordinary DOM.
- TypeScript-first, no external CSS, peer deps — easy to drop into an existing build and theme.
How it compares
Several features voxsheet ships by default require a paid plan or a separate license in other major grids (the table below lists representative examples; licenses change, so verify current terms before adopting).
| Feature | voxsheet | Representative alternatives |
|---|---|---|
| Autofill (fill handle) | Built in, free | AG Grid: Enterprise (paid); MUI X: Premium (paid) |
| Multi-range selection | Built in, free | AG Grid: Enterprise (paid) |
| Server-driven data (host owns fetching) | Built in | AG Grid: Enterprise (Server-Side Row Model) |
| License | MIT | Handsontable: paid license required for commercial use |
Installation
npm install @lembryo/voxsheet
react / react-dom (v18+) are peer dependencies. Remember to import the
stylesheet as well.
import { VoxSheet } from "@lembryo/voxsheet"
import "@lembryo/voxsheet/styles.css"
Quick start
A minimal example. columns, totalRows, and fetchRows are the
three required props.
import { useCallback, useState } from "react"
import { VoxSheet } from "@lembryo/voxsheet"
import type { Column, FetchResult, Query, SortSpec } from "@lembryo/voxsheet"
import "@lembryo/voxsheet/styles.css"
const columns: Column[] = [
{ name: "id", type: "number" },
{ name: "name", type: "string" },
{
name: "salary",
type: "number",
format: { kind: "number", options: { style: "currency", currency: "USD" } },
},
{ name: "joinedAt", type: "date" },
]
export function App() {
const [sort, setSort] = useState<SortSpec[]>([])
const [total, setTotal] = useState(0)
// The grid calls this with a Query (offset/limit + the controlled
// sort/filters/search) and an AbortSignal for cancelling stale requests.
const fetchRows = useCallback(
async (query: Query, signal: AbortSignal): Promise<FetchResult> => {
const res = await fetch("/api/rows", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(query),
signal,
})
const json: FetchResult = await res.json()
if (typeof json.total === "number") setTotal(json.total)
return json
},
[],
)
return (
<VoxSheet
columns={columns}
totalRows={total}
fetchRows={fetchRows}
sort={sort}
onSortChange={setSort}
/>
)
}
onSortChange is set.
Core concepts
VoxSheet is controlled and transport-agnostic. Responsibilities split
as follows.
| Owner | Responsibilities |
|---|---|
| Host | Data source (fetchRows), domain state (sort /
filters / search), persistence (committing edits),
filter-condition UI
|
| Grid | Viewport (virtual scroll), cell selection, edit buffer, undo/redo, keyboard, clipboard |
The host holds sort/filter state (e.g. with useState); the grid only notifies
user actions via callbacks. When state changes, fetchRows is called again with
a new Query.
Data contract
These are the types exchanged between the grid and the host — the heart of working with voxsheet.
CellValue
A cell value, restricted to JSON-serializable types.
type CellValue = string | number | boolean | null
- Carry dates as an ISO 8601 string (recommended) or epoch milliseconds,
interpreted via column
type: "date". nullrepresents SQL NULL and is distinct from the empty string"".
Query
The fetch criteria the grid passes to fetchRows. offset /
limit are chosen by the grid; sort / filters /
search are the controlled values you passed in.
type Query = {
offset: number
limit: number
sort: SortSpec[] // multi-column; array order = priority
filters: FilterSpec[] // AND-combined
search?: string
}
FetchResult
The value returned by fetchRows. data[i][j] is row i, column j, aligned to
columns order.
| Property | Type | Description |
|---|---|---|
| datarequired | CellValue[][] | data[i][j] = row i, col j. Aligned to columns order. |
| idsrequired | number[] | Stable per-row id. Used to resolve row → id when committing edits. |
| ordinalsrequired | number[] | Display ordinal shown in the row-number gutter. |
| totaloptional | number | Count after filters/search; syncs the scrollbar (returning it every time is recommended). |
fetchRows
A function that fetches a window of rows. The second argument, signal, lets you cancel
stale requests.
type FetchRowsFn = (query: Query, signal: AbortSignal) => Promise<FetchResult>
fetchRows a stable reference (e.g. with useCallback).
Passing a new function every render triggers unnecessary refetches.
Columns
Column
Column metadata. type drives rendering, editing, comparison, and autofill. Show/hide a
column by including or omitting it from the array (controlled).
| Property | Type | Description |
|---|---|---|
| namerequired | string | Identifier and display label. Referenced by column in sort /
filter.
|
| typeoptional | "string" | "number" | "date" | "boolean" | Default "string". Basis for formatting, alignment, parsing, comparison.
|
| alignoptional | "left" | "right" | "center" | Derived from type by default (number/date = right, boolean = center). |
| widthoptional | number | Defaults to defaultColumnWidth. User resizes are remembered by column name.
|
| formatoptional | ColumnFormat | Display formatting. See below. |
| editableoptional | boolean | ((ctx) => boolean) | Follows the parent readOnly by default. |
| validateoptional | (value, ctx) => boolean | string | Reject with false or a string (error message). |
| sortModesoptional | { id: string; label: string }[] | Sort-method choices (e.g. text vs numeric). Adds a ▾ picker; the chosen id rides on
SortSpec.mode.
|
| defaultSortModeoptional | string | Initial/default mode id (defaults to sortModes[0]). |
Formatting
Stringify values with Intl options or an arbitrary function. Resolution order is
function > per-kind default.
type ColumnFormat =
| { kind: "number"; options: Intl.NumberFormatOptions }
| { kind: "date"; options: Intl.DateTimeFormatOptions }
| ((value: CellValue, ctx: { column: Column; row: number }) => string)
// Currency
{ name: "salary", type: "number",
format: { kind: "number", options: { style: "currency", currency: "USD" } } }
// Fully custom via a function
{ name: "status", format: (v) => (v === 1 ? "Active" : "Inactive") }
Editing & validation
Use editable for per-column / per-row edit permission, and validate for
input validation.
{
name: "email",
type: "string",
editable: ({ row }) => row > 0, // per-row permission
validate: (value) =>
typeof value === "string" && value.includes("@")
? true
: "Invalid email address", // returning a string rejects + shows the message
}
Props reference
VoxSheetProps grouped by category.
Data
| Property | Type | Description |
|---|---|---|
| columnsrequired | Column[] | Column definitions (controlled). |
| totalRowsrequired | number | Total row count. Synced via FetchResult.total. |
| fetchRowsrequired | FetchRowsFn | Row fetcher (query, signal) => Promise<FetchResult>. |
| queryKeyoptional | unknown | Invalidation key for host-specific query inputs. Changing it drops the cache and refetches. |
Layout & display
| Property | Type | Description |
|---|---|---|
| rowHeightoptional | number | Row height (px). Default 28. Takes precedence over density. |
| densityoptional | "compact" | "normal" | "comfortable" | Switches font and row height together. Default "normal". |
| defaultColumnWidthoptional | number | Default column width (px). Default 120. |
| frozenRowsoptional | number | Rows frozen from the top. Default 0. The first N rows render in a fixed band above the scrolling body (only horizontal scroll is synced). Supported since 1.1.0. |
| frozenColumnsoptional | number | Columns frozen to the left. Default 0. The first N columns stay pinned during horizontal
scroll (sticky-positioned) and compose with frozenRows. Supported since
1.1.0.
|
| themeoptional | "light" | "dark" | "system" | Sets data-vox-theme. Default "system". |
| classNameoptional | string | Class on the root element. |
| styleoptional | CSSProperties | Inline style on the root element. |
Behavior (controlled domain state)
| Property | Type | Description |
|---|---|---|
| readOnlyoptional | boolean | Disables editing UI (copy / select / navigate still work). |
| sortoptional | SortSpec[] | Multi-column sort (controlled). |
| filtersoptional | FilterSpec[] | Filters (controlled; the condition UI is owned by the host). |
| searchoptional | string | Search keyword. |
| searchHighlightsoptional | CellAddress[] | Cells to highlight. |
| currentSearchHitoptional | CellAddress | null | Current hit; scrolls into view when it changes. |
State UI & extension
| Property | Type | Description |
|---|---|---|
| renderLoadingoptional | () => ReactNode | Override the loading view. |
| renderEmptyoptional | () => ReactNode | Override the empty view. |
| labelsoptional | Partial<VoxLabels> | Override built-in UI strings (i18n). |
| iconsoptional | Icons | Override icons. |
| platformoptional | PlatformAdapter | Inject clipboard / notify / confirm / save implementations. |
Events (callbacks)
Buttons and affordances are hidden or disabled when their callback is omitted.
| Property | Type | Description |
|---|---|---|
| onSortChange | (sort: SortSpec[]) => void | Grid toggles none→asc→desc and notifies. With Column.sortModes, the chosen
mode is carried on SortSpec.mode. Omitting hides the sort button.
|
| onFilterButtonClick | (col: number, anchor: DOMRect) => void | Filter request (host shows a popover). Omitting hides the filter button. |
| onColumnResize | (col: number, width: number) => void | Column resize (drag / double-click auto-fit). |
| onColumnRename | (col: number, newName: string) => void | Header rename. Omitting disables rename. |
| onColumnReorder | (from: number, to: number) => void | Column drag-and-drop from the header (shows an insertion indicator). Omitting disables
dragging. Reflect the move by reordering columns (host-controlled).
|
| onAddColumn | (atCol: number) => void | Add-column request. Omitting hides the add button. |
| onCellChange | (edit: CellEdit) => void | On each local edit. |
| onDirtyChange | (hasChanges: boolean) => void | Whether there are uncommitted changes. |
| onAppendRow | (atRow: number) => void | Append a row (Enter on the last row / at the end). |
| onInsertRow | (atRow: number, pos: "above" | "below") => void | Insert-row request. |
| onDeleteRows | (rows: number[]) => void | Delete-rows request. |
| onAutoFill | (p: { sourceRange; direction; toEnd }) => void | Fill-handle drag / double-click. |
| onSelectionChange | (selection: Selection[]) => void | Selection changed. |
| onSelectionStats | (stats: SelectionStats | null) => void | Selection aggregates (sum / average / count). |
| onCellKeyDown | (e, ctx: CellContext) => void | Hook before default key handling (suppress with preventDefault). |
| onError | (err, ctx: { phase: "fetch" | "commit" }) => void | Fetch / commit failure. |
Sorting
Sorting is controlled. On header click the grid toggles none → asc → desc and notifies
via onSortChange; the host updates sort. Array order is the multi-column
priority.
const [sort, setSort] = useState<SortSpec[]>([])
<VoxSheet columns={columns} totalRows={total} fetchRows={fetchRows}
sort={sort} onSortChange={setSort} />
Sort modes
Some columns can be ordered more than one way — as text or as a
number, say. Declare the choices with Column.sortModes and the header
gains a small ▾ picker; the chosen mode id rides along on SortSpec.mode,
and your backend decides what it means (the grid only carries the id, staying
transport-agnostic). Columns without sortModes are unchanged — no mode is
sent, so the default is your backend's plain (text) ordering.
const columns: Column[] = [
{ name: "code", sortModes: [
{ id: "text", label: "Text" },
{ id: "numeric", label: "Numeric" },
], defaultSortMode: "text" },
]
// fetchRows receives e.g. { column: "code", direction: "asc", mode: "numeric" }
// — translate mode → ORDER BY on the server.
Filtering
The filter-condition UI is owned by the host. The grid signals “I want to edit this
column's filter” via onFilterButtonClick(col, anchorRect), so the host shows a popover
near anchorRect and updates filters on confirm. Filters are AND-combined.
type FilterOperator =
| "=" | "!=" | ">" | ">=" | "<" | "<="
| "contains" | "startsWith" | "endsWith"
| "isNull" | "notNull"
type FilterSpec = { column: string; operator: FilterOperator; value?: CellValue }
When a column has more than one active filter, the header filter button shows a small count badge, so multi-condition filters are visible at a glance.
Search
Passing a keyword to search reflects it into Query.search for fetchRows.
The host controls hit highlighting and scrolling via searchHighlights / currentSearchHit.
<VoxSheet ...
search={keyword}
searchHighlights={hits}
currentSearchHit={hits[cursor] ?? null}
/>
Editing & commit
Edits first accumulate in a local layer (dirty highlight, onCellChange
/ onDirtyChange notifications). The host reads getLocalEdits() whenever it
likes, resolves each row to a stable id via the ids received from fetchRows,
persists, then calls clearLocalEdits().
const ref = useRef<VoxSheetHandle>(null)
async function save() {
const edits = ref.current!.getLocalEdits() // CellEdit[]
// resolve row → stable id and persist (e.g. via PATCH)
await commitToServer(edits)
ref.current!.clearLocalEdits() // clear after success
}
<VoxSheet ref={ref} ... onDirtyChange={setDirty} />
type CellEdit = { row: number; col: number; oldValue: CellValue; newValue: CellValue }
Selection & stats
Selection is an array of Excel-like rectangular ranges. onSelectionChange reports the
ranges; onSelectionStats reports count, numeric count, sum, and average
(sum / average are null when there are no numbers).
type CellAddress = { row: number; col: number }
type Selection = { start: CellAddress; end: CellAddress }
type SelectionStats = {
count: number
numericCount: number
sum: number | null
average: number | null
}
Autofill
Dragging or double-clicking the fill handle at the bottom-right of the selection calls onAutoFill.
The actual value-generation logic is up to the host.
onAutoFill={(p) => {
// p.sourceRange: the source selection, p.direction: "down"|"up"|"left"|"right"
// p.toEnd: double-click to fill to the end of the column
}}
Keyboard
| Keys | Action |
|---|---|
| ↑ ↓ ← → / Tab / Enter | Move between cells |
| Home End / PageUp PageDown | Row start/end / page-wise move |
| Ctrl+Home / Ctrl+End | Jump to start/end of the table |
| F2 / type directly / Delete | Start editing / overwrite / clear |
| Ctrl+A/C/X/V | Select all / copy / cut / paste |
| Ctrl+Z / Ctrl+Y | Undo / redo |
| Shift+arrows / Shift+click | Extend the selection |
| Ctrl+click | Multi-range selection |
IME is committed on compositionend. In readOnly mode only copy, select-all,
and navigation are active.
Row freezing
Set frozenRows to keep the first N rows pinned in a fixed band above the scrolling body.
The band stays in place while the rest of the grid scrolls vertically; only horizontal scrolling is
synced to it, so the frozen cells stay aligned with their columns. The scrolling body covers rows
[frozenRows, total), so frozen rows are never shown twice.
Frozen rows behave like ordinary rows — they can be selected and edited, and the leading chunk that
covers them is always fetched regardless of scroll position. The default 0 disables
freezing. To freeze columns instead, see column freezing.
<VoxSheet columns={columns} totalRows={total} fetchRows={fetchRows} frozenRows={2} />
Column freezing
Set frozenColumns to keep the first N columns pinned to the left. They stay visible
while the rest of the grid scrolls horizontally (implemented with sticky positioning); column widths
and row heights stay aligned with the body. Frozen columns compose with frozenRows —
the top-left intersection stays pinned in both directions.
The default 0 disables freezing.
<VoxSheet columns={columns} totalRows={total} fetchRows={fetchRows} frozenColumns={1} />
Column reordering
When onColumnReorder is provided, column headers become draggable. Dragging a header
shows an insertion indicator, and dropping emits onColumnReorder(from, to). The column
order is host-controlled: reflect the move by reordering the columns
array.
Because data is positional (data[i][j] is aligned to columns), make sure
fetchRows returns each row's cells in the current column order after a reorder — return
them from the backend in that order, or remap client-side.
onColumnReorder={(from, to) => {
setColumns((cols) => {
const next = [...cols]
const [moved] = next.splice(from, 1)
next.splice(to, 0, moved)
return next
})
}}
Imperative handle (ref)
Get a VoxSheetHandle via ref to drive scrolling, selection, editing, undo,
and more imperatively.
| Method | Description |
|---|---|
| scrollToRow(row) | Scroll to the given row. |
| scrollToCell(row, col) | Scroll to the given cell. |
| focusCell(row, col) | Focus the given cell. |
| getSelection() | Get the current selection (Selection[]). |
| setSelection(sel) | Set the selection. |
| startEdit(row, col) | Begin editing the given cell. |
| getLocalEdits() | Pull uncommitted edits (CellEdit[]). |
| clearLocalEdits() | Clear local edits and undo/redo after a commit. |
| undo() / redo() | Undo / redo. |
| invalidate() | Drop the cache and refetch (explicit refresh). |
Styling & theming
Styles are self-contained via vox- classes and --vox-* CSS variables.
Import @lembryo/voxsheet/styles.css and override variables or class rules to theme.
Dark mode follows prefers-color-scheme and can be forced via the theme
prop.
.vox-sheet {
--vox-row-height: 32px;
--vox-color-accent: #06c755;
}
--vox-* variables are declared on the .vox-sheet root, not
:root. Overriding them on :root has no effect — scope your overrides to
.vox-sheet (or a wrapper class), as shown above.
densityswitches row height and font together (compact/normal/comfortable).rowHeighttakes precedence overdensity.themesets thedata-vox-themeattribute (light/dark/system).
i18n (labels)
Built-in UI strings can be partially overridden via labels (Partial<VoxLabels>).
<VoxSheet ...
labels={{
loading: "Loading…",
empty: "No data",
contextCopy: "Copy",
contextPaste: "Paste",
}}
/>
See VoxLabels in the type reference for the full list of keys.
Icons
Replace the sort / filter icons. Keys you don't provide use the built-in icons.
<VoxSheet ...
icons={{
filter: ({ size }) => <MyFilterIcon width={size} />,
}}
/>
Replaceable keys: sortAscending / sortDescending /
sortUnsorted / filter / filterActive.
Platform adapter
Inject implementations for clipboard, toast notifications, confirmation dialogs, and file saving. When omitted, the grid falls back to standard browser behavior.
<VoxSheet ...
platform={{
notify: (kind, message) => { showToast(kind, message); return id },
confirm: async ({ message }) => window.confirm(message),
clipboard: {
readText: () => navigator.clipboard.readText(),
writeText: (t) => navigator.clipboard.writeText(t),
},
}}
/>
Type reference
The main public types.
type CellValue = string | number | boolean | null
type ColumnType = "string" | "number" | "date" | "boolean"
type ColumnAlign = "left" | "right" | "center"
type SortDirection = "asc" | "desc"
type SortMode = { id: string; label: string }
type SortSpec = { column: string; direction: SortDirection; mode?: string }
type FilterOperator =
| "=" | "!=" | ">" | ">=" | "<" | "<="
| "contains" | "startsWith" | "endsWith"
| "isNull" | "notNull"
type FilterSpec = { column: string; operator: FilterOperator; value?: CellValue }
type CellAddress = { row: number; col: number }
type Selection = { start: CellAddress; end: CellAddress }
type SelectionStats = {
count: number; numericCount: number
sum: number | null; average: number | null
}
type CellEdit = { row: number; col: number; oldValue: CellValue; newValue: CellValue }
type CellContext = { row: number; col: number; value: CellValue; column: Column }
// Built-in UI strings
type VoxLabels = {
loading: string; empty: string
contextCut: string; contextCopy: string; contextPaste: string
contextInsertRowAbove: string; contextInsertRowBelow: string
contextDeleteRows: string; contextUndo: string; contextRedo: string
sortOptions: string; sortClear: string
confirmLargeCopyTitle: string; confirmLargeCopyMessage: string
confirmOk: string; confirmCancel: string
}
// Icons
type IconName =
"sortAscending" | "sortDescending" | "sortUnsorted" | "filter" | "filterActive"
type IconProps = { size?: number; className?: string }
type Icons = Partial<Record<IconName, (props: IconProps) => ReactElement>>
// Platform
type ToastKind = "loading" | "success" | "error" | "info"
type PlatformAdapter = {
clipboard?: {
readText?: () => Promise<string>
writeText?: (text: string) => Promise<void>
}
notify?: (kind: ToastKind, message: string,
opts?: { id?: string; durationMs?: number }) => string
confirm?: (opts: { title?: string; message: string;
confirmLabel?: string; cancelLabel?: string }) => Promise<boolean>
saveFile?: (opts: { suggestedName: string; mimeType?: string;
data: string | Blob }) => Promise<void>
}
Limitations
- Column virtualization — rows are windowed, but every column renders; keep the column count moderate for very wide tables.
- RTL layout (the
dirprop) is planned.
Header buttons whose callback is omitted are hidden (by design).