@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 modelreact / react-dom are peer dependencies.
  • No external CSS framework — self-contained via --vox-* variables and vox- 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
Choosing the right tool
For breadth of features and maturity — row grouping, pivoting, frozen columns — established full-scale grids (e.g. AG Grid) lead. voxsheet fits “React, free, with Excel-like editing and a server-driven model done simply.” If you need to be fully framework-agnostic, Web Component grids (e.g. RevoGrid) are also worth considering.

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}
        />
    )
}
Note
Header buttons appear only when the matching callback is provided. For example, the sort button shows up only when 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".
  • null represents 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>
Important
Keep 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.

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;
}
Where to override variables
The --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.
  • density switches row height and font together (compact / normal / comfortable).
  • rowHeight takes precedence over density.
  • theme sets the data-vox-theme attribute (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

Not yet available
  • Column virtualization — rows are windowed, but every column renders; keep the column count moderate for very wide tables.
  • RTL layout (the dir prop) is planned.

Header buttons whose callback is omitted are hidden (by design).

@lembryo/voxsheet · MIT License · npm · GitHub