hyper-xl Reference

A React library that delivers the same grid UX as Excel on the web. It covers every shipped feature on a single page.

▶ Live Demo · For an interactive demo powered by the real hyper-xl (grid + live code editor), see View Demo →
License · hyper-xl is distributed under the HyperEZ Source-Available License. Free for evaluation, learning, and non-commercial use. Commercial or production use requires a separate license. Contact support@hyperez.io. See LICENSE.

Introduction

hyper-xl is a React grid component. It faithfully reproduces Excel's keyboard / mouse / clipboard / fill / sort & filter / zoom / annotation / protection behaviors. The data is owned by the consumer, and the library detects user gestures and passes them back through callbacks as typed payloads.

It is virtualized to maintain 60fps even with 10,000+ rows, ships as a single ESM bundle, and exposes design tokens as --xl-react-* CSS custom properties.

Key Features

  • Two-axis virtual scrolling: maintains 60fps with 10k rows × 30 columns
  • Excel TSV bidirectionally compatible clipboard (copy/paste directly between external Excel ↔ grid)
  • Excel-equivalent keyboard mapping + right-click context menu
  • Multi-column sort, per-column value filter (checkbox panel)
  • 10% ~ 400% sheet zoom, Ctrl+wheel + bottom-right widget
  • Per-cell font / alignment / fill / border visual formatting + editing toolbar
  • Per-cell read-only protection (blocks editing / paste / fill / delete)
  • Real-time SUM / AVG / COUNT status bar for the selection

Installation

It is published to the npm registry. A pre-built bundle is included, so you can use it right away without a separate build step.

npm i hyper-xl
# or
pnpm add hyper-xl
# or
yarn add hyper-xl

Peer Dependencies

  • react ^18 || ^19
  • react-dom ^18 || ^19
  • exceljs ^4.4.0: optional (peerDependenciesMeta.exceljs.optional: true). Install it only if you call exportToXlsx / importFromXlsx / exportMultiSheetXlsx / ImportDialog. If you only use CSV / TSV or only the grid, there is no need to install it. ExcelJS is lazy-loaded via await import('exceljs'), so it is not included in the main bundle, and the type declaration file of the root entry ('hyper-xl') does not expose ExcelJS types either. If you need the low-level helpers (buildWorkbookFromSnapshot / parseWorkbookToSnapshot / cellFormatToExcelStyle / cellFormatFromExcelStyle), import them from the 'hyper-xl/exceljs' subpath. This path exposes ExcelJS types, so ExcelJS must be installed.

Quick Start

The smallest grid: just pass column definitions and row data.

import { XlReact, type Column, type Row } from 'hyper-xl';
import 'hyper-xl/styles.css';
import 'hyper-xl/themes/light.css';

const columns: Column[] = [
  { id: 'name', accessor: (r) => r.data.name },
  { id: 'qty',  accessor: (r) => r.data.qty, dataType: 'number' },
];

const rows: Row[] = [
  { id: 1, data: { name: 'Container A', qty: 12 } },
  { id: 2, data: { name: 'Container B', qty: 8  } },
];

export function Page() {
  return <XlReact columns={columns} rows={rows} />;
}
Editable controlled grid example tsx · ~30 lines

Wire up onCellChange and the grid enters editing mode. The consumer reflects the changes in its own state (useState / Redux / Zustand, etc.).

import { useState } from 'react';
import { XlReact, type Column, type Row, type CellChange } from 'hyper-xl';

const columns: Column[] = [
  { id: 'name', accessor: (r) => r.data.name },
  { id: 'qty',  accessor: (r) => r.data.qty, dataType: 'number' },
];

export function EditableGrid() {
  const [rows, setRows] = useState<Row[]>([
    { id: 1, data: { name: 'Container A', qty: 12 } },
    { id: 2, data: { name: 'Container B', qty: 8  } },
  ]);

  const onCellChange = (change: CellChange) => {
    setRows((prev) =>
      prev.map((r, i) =>
        i === change.coord.row
          ? { ...r, data: { ...r.data, [change.columnId]: change.nextValue } }
          : r,
      ),
    );
  };

  return <XlReact columns={columns} rows={rows} onCellChange={onCellChange} />;
}
Unified handling of onCellChange + onCellsClear with useReducer tsx · ~45 lines

In a real app, routing editing / delete / paste / fill all through the same reducer keeps them semantically consistent with the undo stack.

import { useReducer } from 'react';
import {
  XlReact,
  type Row,
  type CellChange,
  type CellsClearPayload,
} from 'hyper-xl';

type Action =
  | { kind: 'set'; row: number; columnId: string; value: unknown }
  | { kind: 'clear'; ranges: CellsClearPayload['ranges'] };

function reducer(state: Row[], action: Action): Row[] {
  switch (action.kind) {
    case 'set':
      return state.map((r, i) =>
        i === action.row
          ? { ...r, data: { ...r.data, [action.columnId]: action.value } }
          : r,
      );
    case 'clear': {
      const next = state.map((r) => ({ ...r, data: { ...r.data } }));
      for (const range of action.ranges) {
        const r0 = Math.min(range.start.row, range.end.row);
        const r1 = Math.max(range.start.row, range.end.row);
        // Column ID mapping is taken from the columns definition (omitted)
        for (let r = r0; r <= r1; r++) {
          // next[r].data[columnId] = undefined;
        }
      }
      return next;
    }
  }
}

Styles & Tokens

The library ships CSS only as a standalone file. The JS entry does not import CSS as a side effect, so it does not conflict with bundlers like Next.js that enforce “no global CSS from node_modules”.

Path Required Contents
hyper-xl/styles.css Required --xl-react-* token defaults + the library's internal BEM class rules
hyper-xl/themes/light.css Optional Light theme token override
CSS token override example css

Because every visual element is driven by --xl-react-* CSS variables, the consumer can override individual tokens without touching the bundle.

/* Change the stripe color of protected cells to the brand color */
.xl-react-grid {
  --xl-react-readonly-stripe: rgba(255, 200, 0, 0.18);
  --xl-react-selection-border: #ff4081;
  --xl-react-selection-bg: rgba(255, 64, 129, 0.12);
}

XlReact Component

XlReact is the library's core grid component. It operates in fully controlled mode, so the grid never owns the row data. Pass columns and rows, and wire up the callbacks corresponding to the features you need. Beyond the grid, auxiliary components such as toolbars and dialogs (CellFormatToolbar, CellMergeToolbar, ConditionalFormatToolbar, FindReplaceDialog, ValidationDropdown, ExportButton, ImportDialog, PivotBuilder, PivotChart, SheetTabBar, PrintPreview, FormulaBar, etc.) are exported from the root as well — pick only the ones you need.

<XlReact
  columns={columns}
  rows={rows}
  rowHeight={28}
  columnWidth={120}
  onCellChange={(change) => applyEdit(change)}
  onCellsClear={(payload) => clearRanges(payload.ranges)}
  freezeFirstRow
  showSelectionStats
/>

The full list of props is in the XlReactProps section.

Columns & Rows

Column

  • idstring

    A stable identifier. Sort / filter / reorder payloads carry this id.

  • accessor(row: Row) => T

    Returns the cell value for the column.

  • dataType'text' | 'number'

    Determines the default editor's input filtering and validity styling. Default 'text'. If set to 'number', non-numeric key input is rejected during editing.

  • requiredboolean

    If the accessor returns null / undefined / '' / NaN, the invalid style is applied. 0 / false are valid values. It is merely a visual cue and does not block commit.

  • validate(value: T, row: Row) => boolean | string

    Value-based validation. Returning a non-empty string marks it invalid + a message, while false marks it invalid with no message.

  • cellRenderer / cellEditor(props) => ReactNode

    Custom cell renderer / editor. The editor receives onCommit / onCancel.

  • widthnumber

    Initial column width (px). It is overridden by the user's drag resize.

  • readOnlyboolean

    Marks all cells of this column as protected. It is a union with the grid's cellProtection prop.

  • validation{ listKey: string; strict?: boolean }

    Connects the column to a named list in validationLists to enable dropdown selection. If strict is true, values not in the list are shown with the invalid style. For details, see the Data Validation (Dropdown) section.

  • autoCompleteboolean

    §2.3: In editing mode, the first candidate among the existing values of the same column that has the input value as a prefix is suggested as inline ghost-text. Accept it with Tab / (when the caret is at the end), and close only the candidate with Esc. Case-insensitive (prefix matching): the original case is used when accepted. Default false.

Row

  • idstring | number

    A stable identifier. Delete / reorder payloads use this id.

  • dataRecord<string, unknown>

    Opaque row data. The grid never reads it directly; it only invokes Column.accessor.

  • heightnumber

    Initial row height (px).

Custom cell renderer: progress bar tsx · ~25 lines
const progress: Column<number> = {
  id: 'progress',
  accessor: (r) => r.data.progress as number,
  dataType: 'number',
  cellRenderer: ({ value }) => (
    <div style={{ position: 'relative', height: '100%' }}>
      <div
        style={{
          position: 'absolute',
          inset: 0,
          width: `${Math.max(0, Math.min(100, value))}%`,
          background: 'rgba(11, 83, 148, 0.18)',
        }}
      />
      <span style={{ position: 'relative' }}>{value}%</span>
    </div>
  ),
};
Custom editor: select box tsx · ~30 lines
const statusCol: Column<'todo' | 'doing' | 'done'> = {
  id: 'status',
  accessor: (r) => r.data.status as 'todo' | 'doing' | 'done',
  cellEditor: ({ value, onCommit, onCancel }) => (
    <select
      autoFocus
      defaultValue={value}
      onBlur={(e) => onCommit(e.target.value as typeof value)}
      onKeyDown={(e) => {
        if (e.key === 'Escape') onCancel();
      }}
    >
      <option value="todo">Todo</option>
      <option value="doing">Doing</option>
      <option value="done">Done</option>
    </select>
  ),
};

Controlled data model

The grid never owns the rows / columns. It detects user gestures and exposes typed payloads, then trusts the consumer to update its own state. You only need to wire the callbacks for the features you intend to use.

Gesture Callback Payload
Commit edit onCellChange CellChange { coord, columnId, prevValue, nextValue }
Delete selection onCellsClear CellsClearPayload { ranges }
Selection change onSelectionChange SelectionSnapshot { active, ranges }
Edit request (F2 / dblclick) onEditRequest CellCoord

Cell selection

FeatureHigh The core selection system, supporting single / range / multiple non-contiguous selections.

Feature

  • Click to set the active cell. F2 / double-click to enter edit mode.
  • Enter(↓) / Tab(→) / Shift+Enter(↑) / Shift+Tab(←) to move. Esc cancels editing.
  • Drag to select a rectangular range. Shift+click / Shift+arrow to extend the range.
  • Ctrl+A toggles the data region → the entire sheet. Shift+Ctrl+arrow extends to the end of the data.
  • Ctrl+click / Ctrl+drag to add a non-contiguous range.
  • Click a row / column header to select the entire axis.

API

  • onSelectionChange(snapshot: SelectionSnapshot) => void

    Called whenever the selection the user sees (the active cell or the range list) changes. It is not called on the initial mount.

Sync selection state to the outside tsx
import { useState } from 'react';
import { XlReact, type SelectionSnapshot } from 'hyper-xl';

export function Page() {
  const [sel, setSel] = useState<SelectionSnapshot | null>(null);

  return (
    <>
      <XlReact columns={cols} rows={rows} onSelectionChange={setSel} />
      <p>
        Active: ({sel?.active.row ?? '-'}, {sel?.active.col ?? '-'}) · Ranges:{' '}
        {sel?.ranges.length ?? 0}
      </p>
    </>
  );
}
Related types typescript
interface CellCoord { row: number; col: number; }
interface SelectionRange { start: CellCoord; end: CellCoord; }
interface SelectionSnapshot {
  active: CellCoord;
  ranges: ReadonlyArray<SelectionRange>;
}

Editing & validation

FeatureHigh An input flow on par with Excel, including the Korean IME.

Edit entry modes

  • edit: F2 / double-click. Initial draft = current value, fully selected.
  • overwrite: a printable key. draft = the typed character.
  • clear: Backspace. draft = empty string.

Enter / Tab / focus loss commits → onCellChange. Esc cancels. In edit mode, Alt+Enter inserts a \n at the current caret position and keeps editing active (§2.1). Line breaks are not applied during IME composition. To visually preserve line breaks in the cell display, the cell must have align.wrap: true set (pre-wrap). When a column is enabled with autoComplete: true, prefix-matching candidates from that column's value pool are shown as ghost-text and can be accepted with Tab or (when the caret is at the end), or dismissed (the candidate only) with Esc (§2.3).

Validation

  • Column.required: applies the invalid style to empty values. It does not block the commit.
  • Column.validate(value, row): value-based validation. true / false / a message string.
  • Column.dataType: 'number': the default editor rejects non-numeric key input.

API

  • onCellChange(change: CellChange) => void

    Strictly required to enable editing. Without it, the grid is implicitly read-only.

  • onCellsClear(payload: CellsClearPayload) => void

    Called when the Delete key is pressed on a non-empty selection.

  • onEditRequest(coord: CellCoord) => void

    For notification on F2 / dblclick. Independent of the actual mutation pipeline.

  • readOnlyboolean

    A force switch that blocks edit entry even when onCellChange is wired.

required + validate combination example tsx · ~25 lines

required checks for empty values; validate checks domain rules. If both are invalid, they are applied as a union.

const qty: Column<number> = {
  id: 'qty',
  accessor: (r) => r.data.qty as number,
  dataType: 'number',
  required: true,
  validate: (value, row) => {
    if (value < 0) return 'Quantity must be 0 or greater';
    const capacity = row.data.capacity as number;
    if (value > capacity) return `Exceeds capacity (${capacity})`;
    return true;
  },
};
CellChange handling reducer tsx
const onCellChange = (change: CellChange) => {
  // change = { coord: { row, col }, columnId, prevValue, nextValue }
  setRows((prev) =>
    prev.map((r, i) =>
      i === change.coord.row
        ? { ...r, data: { ...r.data, [change.columnId]: change.nextValue } }
        : r,
    ),
  );
};

Clipboard (TSV)

FeatureHigh Bidirectional copy / cut / paste compatible with Excel.

Behavior

  • Ctrl+C: writes the selection to the OS clipboard as TSV + shows the marching-ants dashed outline.
  • Ctrl+X: copies, then clears the source cells via onCellsClear.
  • Ctrl+V: reads the clipboard, parses the TSV, then fires onCellChange cell by cell. A single-cell source fills the target range.
  • Right-click ▸ Paste Special: notification via onPasteSpecialRequest only. The host renders the dialog.

API

  • onCopy(payload: ClipboardCopyPayload) => void
  • onCut(payload: ClipboardCopyPayload) => void
  • onPasteRequest(payload: PasteRequestPayload) => void

    A notification fired on every Ctrl+V. It does not replace the automatic paste — when not readOnly and onCellChange is wired up, the automatic paste (onCellChange) still runs alongside it. To turn off the automatic paste and handle it yourself, use it together with readOnly (see the example below).

  • onPasteSpecialRequest(payload: PasteSpecialRequestPayload) => void
Note: pasting into typed columns: The nextValue carried by onCellChange on an automatic paste is always a string (the clipboard text). Coerce number / date columns inside your reducer.
Coerce on paste into a number column tsx
const onCellChange = (change: CellChange) => {
  const col = columns.find((c) => c.id === change.columnId);
  let next = change.nextValue;
  if (col?.dataType === 'number' && typeof next === 'string') {
    const trimmed = next.trim();
    next = trimmed === '' ? null : Number(trimmed);
  }
  applyEdit({ ...change, nextValue: next });
};
Apply paste directly via onPasteRequest (onCellChange disabled) tsx
<XlReact
  columns={columns}
  rows={rows}
  readOnly /* automatic paste disabled */
  onPasteRequest={async (payload) => {
    const text = await navigator.clipboard.readText();
    const tsv = text.split('\n').map((line) => line.split('\t'));
    applyTsvAt(payload.coord, tsv);
  }}
/>

Fill handle & shortcuts

FeatureMedium Excel-style fill handle + Ctrl+D / Ctrl+R / Ctrl+Enter.

Feature

  • Drag the active cell's fill handle (the small square at the bottom-right) to adjacent cells.
  • A single value → repeats. Two seed values → a linear sequence (e.g. 1,2 → 3,4,5).
  • Date sequences are auto-detected by step (day/month/year).
  • Double-click the handle → auto-fills to the end of the left column's data.
  • Ctrl+D fills from top to bottom.
  • Ctrl+R fills from left to right.
  • Ctrl+Enter fills the entire selection with the active value.

Every fill cell flows through onCellChange. Protected cells are automatically excluded.

Bundle the fill with external side effects tsx

onCellChange is called identically for fill / paste / typed-edit alike. If you need to distinguish them, you can batch the burst arriving at the same reducer.

let scheduled = false;
const queue: CellChange[] = [];

const onCellChange = (change: CellChange) => {
  queue.push(change);
  if (!scheduled) {
    scheduled = true;
    queueMicrotask(() => {
      flush(queue.splice(0));
      scheduled = false;
    });
  }
};

Undo / redo

FeatureHigh An undo stack bounded by entry count and bytes.

Behavior

  • Ctrl+Z undo, Ctrl+Y / Ctrl+Shift+Z redo.
  • Cell value changes (edit, paste, fill, delete, cut) are recorded as cell-edits commands. Structural changes such as row/column insert, delete, reorder, and sort are currently outside the stack's scope (they would require a snapshot of the consumer's row ordering).
  • The inverse operation is replayed via onCellChange → the reducer must be written so that (coord, prev) is the inverse of a clear.
  • Defaults: 100 entries / 8 MiB. The spec guarantees a minimum of 50 entries.

API

  • enableUndoboolean

    Defaults to true when onCellChange is wired. Opt out with false when managing history externally.

  • undoMaxEntriesnumber
  • undoMaxBytesnumber
Using an external BoundedUndoStack (enableUndo=false) tsx
import { BoundedUndoStack, type CellChange } from 'hyper-xl';

const undoStack = new BoundedUndoStack({ maxEntries: 200, maxBytes: 16 * 1024 * 1024 });

const onCellChange = (change: CellChange) => {
  undoStack.push({ kind: 'edit', change });
  applyEdit(change);
};

const onUndo = () => {
  const entry = undoStack.pop();
  if (entry?.kind === 'edit') {
    applyEdit({
      ...entry.change,
      prevValue: entry.change.nextValue,
      nextValue: entry.change.prevValue,
    });
  }
};

Row / column manipulation

FeatureHigh Resize / insert / delete / reorder / hide / freeze.

Resizing

  • Drag a header border to adjust the width / height.
  • Double-click a column header border → fit to content (AutoFit). Double-click a row header border → reset to the default height.
  • Applies in bulk when multiple headers are selected.
  • The minimum is controlled by minColumnWidth / minRowHeight.

Insert / delete / reorder

Each gesture exposes a typed payload. Because the grid does not own rows / columns, the consumer mutates the arrays directly.

  • onRowsInsert(payload: RowsInsertPayload) => void

    { atIndex, position: 'above' | 'below', count }. When the prop is unset, the menu item itself is hidden.

  • onRowsDelete(payload: RowsDeletePayload) => void

    { rowIds, rowIndices }: contiguous or multiple selection.

  • onColumnsInsert(payload: ColumnsInsertPayload) => void
  • onColumnsDelete(payload: ColumnsDeletePayload) => void
  • onRowsReorder(payload: RowsReorderPayload) => void

    { rowIds, rowIndices, targetIndex }. targetIndex is the destination after removal: apply it with splice(targetIndex, 0, ...moved).

  • onColumnsReorder(payload: ColumnsReorderPayload) => void
  • Row / Column hidinginternal state (not a callback)

    Hide / unhide is handled as internal grid visibility state (no consumer callback, the same as freeze panes). It works via the right-click menu's "Hide / Unhide" or Ctrl+9·Ctrl+0 (hide), Ctrl+Shift+9·Ctrl+Shift+0 (unhide), and the grid corrects the coordinate mapping on its own. The related payloads (RowsHidePayload, etc.) are internal-only and are not exported from the root.

Freeze panes

  • freezeFirstRow / freezeFirstColboolean

    Equivalent to freezeRowCount: 1 / freezeColCount: 1.

  • freezeRowCount / freezeColCountnumber

    Freezes the first N rows / columns. Overrides freezeFirstRow / freezeFirstCol.

Map onRowsInsert / onRowsDelete to the consumer reducer tsx
const onRowsInsert = (p: RowsInsertPayload) => {
  setRows((prev) => {
    const idx = p.position === 'above' ? p.atIndex : p.atIndex + 1;
    const inserted = Array.from({ length: p.count }, (_, i) => ({
      id: crypto.randomUUID(),
      data: {},
    }));
    const next = prev.slice();
    next.splice(idx, 0, ...inserted);
    return next;
  });
};

const onRowsDelete = (p: RowsDeletePayload) => {
  const toDelete = new Set(p.rowIds);
  setRows((prev) => prev.filter((r) => !toDelete.has(r.id)));
};
Header drag reorder (using rowsReorder.targetIndex) tsx
const onRowsReorder = (p: RowsReorderPayload) => {
  setRows((prev) => {
    const moving = new Set(p.rowIndices);
    const remaining = prev.filter((_, i) => !moving.has(i));
    const moved = p.rowIndices.map((i) => prev[i]);
    remaining.splice(p.targetIndex, 0, ...moved);
    return remaining;
  });
};

Row hierarchy (Grouping)

FeatureMedium PRD §6.4: a multi-level parent/child tree, collapse state held by the consumer, with ▶ / ▼ disclosure widgets and automatic per-level indentation applied in the first data column.

Data model

Adds two optional fields to Row to express hierarchy. Both are optional, so existing flat data keeps working unchanged.

  • Row.levelnumber (optional)

    Tree depth (0 = root). The parent is inferred from runs of adjacent level values, so you usually only need to keep pre-order without a parentId.

  • Row.parentIdRow['id'] | null (optional)

    Explicit parent id. When specified, it overrides inference; an unknown id is ignored and falls back to level inference.

Controlled pattern

The grid does not own the collapse state. The consumer holds collapsedIds: Set<RowId>, derives the visible rows / outline arrays with the pure helper computeRowOutline, and hands them to the grid: this is the same controlled-prop pattern as sortState / filterState.

XlReact prop

  • rowOutlineReadonlyArray<RowOutlineCell | null | undefined>

    Its length must equal rows.length. Each entry is { level, hasChildren, collapsed }; a null / undefined entry excludes that row from indentation / widget targeting.

  • onRowOutlineToggle(rowIndex: number, next: 'collapse' | 'expand') => void

    Called when ▶ / ▼ is clicked. If you do not fix its identity with useCallback, the first column's cell re-renders on every render.

  • rowOutlineIndentPxnumber (default 16)

    Left indentation width per level. Both parents and leaves reserve the disclosure slot at the same width to keep alignment.

Pure helper

  • buildRowTree(rows)RowTree

    Computes the parent / child / level maps in a single pass. A duplicate id uses only its first occurrence (later ones are ignored). Can be cached on the rows identity with useMemo.

  • computeRowOutline(rows, collapsedIds, tree?){ visibleRows: Row[]; outline: RowOutline }

    Produces the visible rows and outline metadata together in a single O(N) pass. Pass the result straight to <XlReact rows={visibleRows} rowOutline={outline} />.

  • toggleRowCollapse(collapsedIds, rowId)Set<RowId>

    Returns a new copy without mutating the Set (adds ↔ removes an id). For the onRowOutlineToggle handler.

  • collapseAtLevels(rows, levels, tree?)Set<RowId>

    Collects the ids of all rows that have children at the given depth. Suitable for seeding the initial collapsedIds (for example, collapsing everything down to the team level).

  • collectRowDescendantIds(rows, rowId, tree?)Set<RowId>

    The Set of all descendant ids of the given row. Used for bulk collapsing / expanding a subtree.

Type

  • RowOutlineCell{ level: number; hasChildren: boolean; collapsed: boolean }
  • RowOutlineReadonlyArray<RowOutlineCell | null>
  • RowTree{ childrenByParent; parentById; levelById; indexById }
  • RowIdRow['id']
Department → team → employee multi-level grouping (consumer reducer) tsx
import { useCallback, useMemo, useState } from 'react';
import {
  XlReact,
  computeRowOutline,
  toggleRowCollapse,
  collapseAtLevels,
  type Row,
  type RowId,
} from 'hyper-xl';

const source: Row[] = [
  { id: 'sales',  data: { name: 'Sales Dept.' },     level: 0 },
  { id: 'kr',     data: { name: 'Domestic Sales' },   level: 1 },
  { id: 'kim',    data: { name: 'Kim (Sales)' },     level: 2 },
  { id: 'lee',    data: { name: 'Lee (Sales)' },     level: 2 },
  { id: 'global', data: { name: 'Overseas Sales' },   level: 1 },
  { id: 'park',   data: { name: 'Park (Sales)' },     level: 2 },
];

export function Org() {
  const [collapsed, setCollapsed] = useState<Set<RowId>>(
    () => collapseAtLevels(source, [1]), // Start collapsed down to the team level.
  );
  const { visibleRows, outline } = useMemo(
    () => computeRowOutline(source, collapsed),
    [collapsed],
  );
  const onToggle = useCallback(
    (rowIndex: number) => {
      const row = visibleRows[rowIndex];
      if (row) setCollapsed((prev) => toggleRowCollapse(prev, row.id));
    },
    [visibleRows],
  );
  return (
    <XlReact
      columns={[{ id: 'name', accessor: (r) => r.data.name }]}
      rows={visibleRows}
      rowOutline={outline}
      onRowOutlineToggle={onToggle}
      rowOutlineIndentPx={18}
    />
  );
}

Note

  • The disclosure widget renders only in the first data column (columnIndex 0). It coexists with merged / frozen rows without conflicts.
  • A widget click blocks cell activation / entering editing via stopPropagation.
  • Virtualization only looks at the visibleRows length, so performance holds even for a large tree regardless of the collapse state.
  • The source array is assumed to be pre-order (parent → child order). Otherwise, specify parentId explicitly.

Sort & Filter

FeatureHigh Controlled multi-column sort + per-column value filter.

Sort

  • A header click cycles a single column through ascdescnone.
  • Shift+click extends to multi-column sort: the resulting SortState is an ordered array of sort keys.
  • Right-click ▸ Sort ▸ Ascending / Descending maps to onSortAscending / onSortDescending.
  • Right-click ▸ Sort ▸ Custom maps to onSortCustomRequest.
  • The grid does not reorder rows. The consumer sorts in response to the callback.

Filter

  • The funnel button on each column header → a dropdown of unique-value checkboxes.
  • The state is sparse: when all options are selected, that column is removed from FilterState.
  • You must pass filterPanelRows (the pre-filter source) to get an Excel-correct unique-value list.
  • Right-click ▸ Filter ▸ Filter by selected value / Clear filter each map to a separate callback.

API

  • sortState / onSortStateChange SortState | (next: SortState) => void

    Controlled sort. Passing onSortStateChange enables the click-to-sort header arrows. sortState is used to show the current sort state and to compute the next state (it is not the activation condition).

  • filterState / onFilterStateChange FilterState | (next: FilterState) => void

    Controlled filter. Passing onFilterStateChange enables the header filter buttons. filterState is used to show the current filter state and to compute the next state (it is not the activation condition).

  • filterPanelRowsReadonlyArray<Row>

    The unfiltered source for the filter dropdown (optional).

Applying the sort on the consumer side tsx
import { useMemo, useState } from 'react';
import { XlReact, type SortState } from 'hyper-xl';

const [sortState, setSortState] = useState<SortState>([]);

const sortedRows = useMemo(() => {
  if (sortState.length === 0) return rows;
  return [...rows].sort((a, b) => {
    for (const key of sortState) {
      const col = columns.find((c) => c.id === key.columnId);
      if (!col) continue;
      const av = col.accessor(a);
      const bv = col.accessor(b);
      if (av === bv) continue;
      const dir = key.direction === 'asc' ? 1 : -1;
      return (av > bv ? 1 : -1) * dir;
    }
    return 0;
  });
}, [rows, sortState]);

return (
  <XlReact
    columns={columns}
    rows={sortedRows}
    sortState={sortState}
    onSortStateChange={setSortState}
  />
);
Narrowing rows with FilterState tsx
import { useMemo, useState } from 'react';
import { XlReact, valueToFilterKey, type FilterState } from 'hyper-xl';

const [filterState, setFilterState] = useState<FilterState>({});

const filteredRows = useMemo(() => {
  const filters = Object.entries(filterState);
  if (filters.length === 0) return rows;
  return rows.filter((r) =>
    filters.every(([columnId, { selectedValues }]) => {
      const col = columns.find((c) => c.id === columnId);
      if (!col) return true;
      return selectedValues.has(valueToFilterKey(col.accessor(r)));
    }),
  );
}, [rows, filterState]);

<XlReact
  columns={columns}
  rows={filteredRows}
  filterState={filterState}
  onFilterStateChange={setFilterState}
  filterPanelRows={rows}
/>
Sort / filter related types typescript
type SortDirection = 'asc' | 'desc';
interface SortColumnEntry { columnId: string; direction: SortDirection; }
type SortState = ReadonlyArray<SortColumnEntry>;

interface ColumnValueFilter { selectedValues: ReadonlySet<string>; }
type FilterState = Readonly<Record<string, ColumnValueFilter>>;

const BLANK_FILTER_KEY = '__xl_react_blank__';

Virtualization & Performance

FeatureHigh Two-axis virtualization keeps 60fps even at 1M+ cells.

Characteristics

  • Row / column virtualization is always ON. No opt-in required.
  • Cell components are reused via React.memo.
  • Scrolling is coalesced with requestAnimationFrame.
  • Large paste / fill (10k+) is split into chunks with the processInChunks helper.
  • The undo stack is limited by entry count + bytes (undo).

API

  • overscannumber

    The number of rows / columns pre-rendered outside the viewport.

  • rowHeightnumber
  • columnWidthnumber
Applying 10k rows asynchronously with processInChunks ts
import { processInChunks } from 'hyper-xl';

await processInChunks(
  largePasteCells,
  (cell) => applyEdit(cell),
  { chunkSize: 500 },
);

Keyboard & Context Menu

FeatureHigh An Excel-equivalent keyboard map and right-click menu.

Keyboard map

KeyAction
← ↑ → ↓Move one cell
Tab / Shift+TabRight / Left
Enter / Shift+EnterDown / Up
HomeFirst cell of the row
Ctrl+HomeA1
End → arrow keyJump to the end of the data
Ctrl + arrow keyEnd of the data region
Page Up / DownPage up / down
Alt+Page Up / DownPage left / right
F2Edit the active cell
EscCancel editing (if AutoComplete suggestions are visible, first close only the suggestions)
Alt+EnterInsert a line break inside the cell in edit mode (§2.1)
Tab / Accept the AutoComplete suggestion in edit mode ( only when the caret is at the end, §2.3)
DeleteDelete the value
Ctrl+1onCellFormatRequest
Ctrl+Z / Ctrl+Y / Ctrl+Shift+ZUndo / Redo / Redo
Ctrl+C / X / VCopy / Cut / Paste
Ctrl+Shift+C / VCopy format / Paste format: Format Painter (§7.3)
Ctrl+D / R / EnterFill (down / right / selection)
Ctrl+F / Ctrl+HFind / Replace dialog (§9)
Ctrl+G / F5Go To cell dialog
Ctrl+Space / Shift+SpaceSelect the active column / active row
Ctrl+9 / Ctrl+0Hide selected rows / columns
Ctrl+Shift+9 / Ctrl+Shift+0Unhide hidden rows / columns around the selection
Ctrl+; / Ctrl+Shift+;Insert today's date / current time (onCellChange path)

Right-click context menu

The menu's items vary depending on the right-click target (row / column / cell). Instead of mutating the grid, each item exposes a typed callback. Wiring a callback enables the menu item; when it is not set, the item itself is hidden.

  • Cut / Copy / Paste / Paste Special…
  • Insert row / column above · below · left · right
  • Delete row / column
  • Sort ▸ Ascending / Descending / Custom…
  • Filter ▸ Filter by selected value / Clear filter
  • Freeze panes (based on the active cell, internal state)
  • Format cells… (onCellFormatRequest)
  • Insert note… / Hyperlink… (onInsertNoteRequest / onInsertHyperlinkRequest)

Find & Replace

FeatureHigh Find and replace values: a grid built-in dialog.

Characteristics

  • Grid built-in: works without any extra wiring. Focus the table and open it with the shortcut.
  • Options: case-sensitive · whole match (entire cell contents) · regex ($1 backreference supported).
  • Scope: entire sheet / selection. When 2 or more cells are selected, the scope defaults to the selection.
  • Find All: shows a list of matching cells (row-first), and clicking one navigates and scrolls to that cell.
  • Replace / Replace All are handled through the existing onCellChange path. They are grouped into a single undo entry, numeric columns convert the value the same way as editing, and protected cells (cell protection) are skipped and reported via onProtectedAction({ action: 'replace' }).
  • The search target is the string of the raw value (not the display string with number formatting applied), so that the replaced value can be passed straight back through onCellChange.

Shortcuts

KeyAction
Ctrl+F / Cmd+FFind dialog
Ctrl+H (Windows/Linux)Replace dialog
⌥⌘F (Cmd+Opt+F, macOS)Replace: alternate shortcut because it overlaps with macOS Cmd+H (hide window)
Enter / Shift+EnterFind next / previous
EscClose

Disable

You can turn off the built-in dialog with enableFindReplace={false}. When turned off, the grid does not intercept Ctrl+F, so the browser's native search works, and to let you attach your own search UI it exports pure helpers (findMatches / replaceInValue) and the FindReplaceDialog component.

API

  • enableFindReplaceboolean

    Enables built-in find / replace. Default true.

  • findMatches(args: FindMatchesArgs) => FindMatch[]

    Row-first list of matches. An invalid regular expression throws InvalidRegexError.

  • replaceInValue(value, query, replacement, options) => string | null

    The substitution result for a single cell value (null when there is no match).

Direct find / replace with the pure engine typescript
import { findMatches, replaceInValue } from 'hyper-xl';

const matches = findMatches({
  rows,
  columns,
  query: 'box',
  options: { matchCase: false, wholeCell: false, useRegex: false },
  scope: 'sheet',
});
// matches[0] === { coord, columnId, value }

const next = replaceInValue('Box 12', 'box', 'Crate', {
  matchCase: false,
  wholeCell: false,
  useRegex: false,
});
// next === 'Crate 12'

Sheet zoom

FeatureMedium 10% ~ 400% zoom, Ctrl+wheel + bottom-right widget.

Characteristics

  • Zoom is linear: row height, column width, gutter, header lane, and font size all scale together.
  • Size multiplication rather than CSS transform: virtualization coordinates and font rendering stay pixel-aligned.
  • Bottom-right widget: / + / slider / percentage button (clicking resets to 100%).
  • Ctrl + 휠 zooms in / out centered around the cursor.
  • Both controlled (zoom + onZoomChange) and uncontrolled (defaultZoom) are supported.

API

  • zoom / defaultZoomnumber

    1.0 is 100%. Range 0.1 ~ 4.0.

  • onZoomChange(zoom: number) => void

    Fires in both the controlled and uncontrolled cases.

  • showZoomControlboolean

    Whether the bottom-right widget is shown. Default true.

  • zoomMin / zoomMaxnumber
Controlled zoom + external toggle tsx
import { useState } from 'react';

const [zoom, setZoom] = useState(1);

return (
  <>
    <button onClick={() => setZoom(1)}>100%</button>
    <button onClick={() => setZoom(2)}>200%</button>
    <XlReact
      columns={columns}
      rows={rows}
      zoom={zoom}
      onZoomChange={setZoom}
      zoomMin={0.5}
      zoomMax={3}
    />
  </>
);

View modes (split / new window / fullscreen)

FeatureMedium §13: gridline and header toggle (P1), 4-pane panels, new-window sync, Fullscreen API.

Characteristics

  • The two boolean props showGridlines · showHeaders add modifier classes to the grid root, which take effect immediately via CSS.
  • showHeaders={false} hides the column header lane and row gutter with visibility: hidden. The layout space is preserved so that the virtualization coordinate / hit-test formulas stay intact.
  • SplitPaneView draws 1 / 2 (left-right or top-bottom) / 4 pane layouts with CSS grid and synchronizes scrolling between paired panes.
  • Scroll synchronization uses an "expected coordinate marker" approach: even when two pairs scroll simultaneously in the same frame, they do not block each other's synchronization (feedback-loop guard).
  • useFullscreen(ref) wraps the Fullscreen API and vendor-prefix fallbacks. If a component unmounts while holding the fullscreen lock, exitFullscreen() is called automatically.
  • useWorkbookBroadcast<T>(channelName) synchronizes messages between same-origin windows via BroadcastChannel. It does not receive its own messages, and in unsupported environments it degrades safely to a no-op.
  • openSheetInNewWindow() is a helper used to open the current URL in a new window and spin up a second listener on the same broadcast channel.
  • SplitPaneView is @experimental: in the future it will replace DOM class-name coupling with the grid's explicit scrollTo API.

API

  • showGridlinesboolean

    Default true. When false, the right and bottom border colors of cells are made transparent for a canvas-like appearance. Headers, the selection area, frozen split lines, and user-defined cell borders are kept as is.

  • showHeadersboolean

    Default true. When false, the column header lane and row gutter visually disappear. The layout space is preserved, and if the host wants to fill the entire area with data, it can crop the outer edges with clip-path/overflow.

  • SplitPaneView{ mode, renderPane, rowSplit?, colSplit?, ariaLabel?, className? }

    mode='none'|'horizontal'|'vertical'|'quad'. renderPane(paneId) returns, for each pane (tl/tr/bl/br), an XlReact (or arbitrary content). Scrolling between pairs is synchronized automatically.

  • useFullscreen(ref){ isFullscreen, isSupported, request, exit, toggle }

    Calls the Fullscreen API on the element referenced by ref. It automatically subscribes to the fullscreenchange event, so it stays in sync even when the user exits with ESC.

  • useWorkbookBroadcast<T>(channelName){ latest, broadcast, isSupported }

    Sends and receives an arbitrary payload to/from another same-origin tab. Note: the payload is serialized on the main thread via structured clone, so sending a 100k-row workbook on every keystroke will freeze the UI. Send large workbooks as a diff or as imperative messages.

  • openSheetInNewWindow(options?)Window | null

    A wrapper around window.open. The default target is 'xl-react-new-window' (reusable), and the default features are width/height 1100×700. Returns null when the popup is blocked.

2-pane split + new-window sync tsx
import { useEffect } from 'react';
import {
  XlReact,
  SplitPaneView,
  useWorkbookBroadcast,
  openSheetInNewWindow,
  type Row,
} from 'hyper-xl';

function WorkbookView({ columns, rows, setRows }) {
  const sync = useWorkbookBroadcast<{ rows: Row[] }>('workbook/main');

  useEffect(() => { sync.broadcast({ rows }); }, [rows]);
  useEffect(() => {
    if (sync.latest) setRows(sync.latest.rows);
  }, [sync.latest]);

  return (
    <>
      <button onClick={() => openSheetInNewWindow()}>Open new window</button>
      <SplitPaneView
        mode="horizontal"
        renderPane={() => <XlReact columns={columns} rows={rows} />}
      />
    </>
  );
}
Fullscreen toggle tsx
import { useRef } from 'react';
import { XlReact, useFullscreen } from 'hyper-xl';

function FullscreenGrid({ columns, rows }) {
  const ref = useRef<HTMLDivElement>(null);
  const fs = useFullscreen(ref);
  return (
    <div ref={ref}>
      {fs.isSupported && (
        <button onClick={() => fs.toggle()}>
          {fs.isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
        </button>
      )}
      <XlReact columns={columns} rows={rows} />
    </div>
  );
}

Print (preview / pagination / header·footer)

FeatureMedium §12: print preview · print area / page breaks · header·footer placeholders · repeating rows/columns · paper/orientation/scale/margins.

Characteristics

  • 4-layer separation. A pure paginate() engine (DOM-agnostic), the resolvePlaceholders() header/footer syntax, the usePrintController state hook, and the PrintPreview preview modal. Each layer can be used independently.
  • Greedy page packing. Columns are filled left→right, and when they exceed the width a new column band starts; within each column band, rows are filled top→bottom. The page order is Excel's default "down, then over" (Down, then Over).
  • Placeholder syntax (Excel-compatible). &P page number, &N total pages, &D date, &T time, &F filename, &A sheet name. && is a literal &. An unknown &X passes through as is (the host interprets style markers).
  • 3-zone header/footer. Three areas: left / center / right. Each area resolves placeholders independently. If all are empty, the page top/bottom band itself disappears and the body area expands.
  • Print area (printArea). When specified as a SelectionRange, everything outside that rectangle is completely excluded from pagination. When null, it is the entire grid.
  • Repeating rows/columns (repeat). A header band that is printed repeatedly at the top/left of every page. It is automatically excluded from the indices so that it is not included in the body, preventing duplicate output.
  • Scale (scale). Clamped to 0.1 ~ 4.0. Since the content is shrunk with transform-scale, the paper size stays the same but more fits on a single page.
  • Page-break preview overlay. PageBreakOverlay takes, from paginate(), the rowPageBreaks/colPageBreaks and draws dashed lines at absolute positions over the live grid (pointer events pass through).
  • Actual printing. startPrint() toggles, on <body>, the xl-react-printing class and calls window.print(). The @media print rules hide every element except the preview and apply page-break-after to each .xl-react-print-page. The class is removed automatically by whichever comes first, the afterprint event or a 4-second timeout (with a guard against double invocation).
  • Accessibility. The modal has role="dialog" + aria-label, auto-focus, and closes on ESC / backdrop click. Clicking a page card updates the "current page" in the modal footer.

API

  • paginate(input)PaginationResult

    A pure function. It takes the row/column counts · dimensions · options · header/footer band pixels and returns the page array, page-boundary indices, and the paper/usable-area sizes. Since it does not depend on React, it can be reused as is in other renderers such as PDF export.

  • resolvePrintOptions(options?)ResolvedPrintOptions

    Fills a sparse options object with the module defaults, clamps scale to [0.1, 4.0], and normalizes ranges. Shared by the preview and the controller hook.

  • resolvePlaceholders(template, ctx)string

    Expands the &X markers in a single area's text. ctx is { pageNumber, totalPages, date?, filename?, sheetName?, formatDate?, formatTime? }. The default date format is ISO-8601 and the time is HH:MM; you can freely replace them with Intl.DateTimeFormat or similar.

  • usePrintController({ initial? })PrintController

    { options, resolved, isOpen, update, setPrintArea, setRepeatRows, setRepeatCols, open, close, reset }. A thin wrapper that manages the options as consumer state. update is always a sparse patch.

  • PrintPreview{ open, onClose, rows, columns, rowHeights?, colWidths?, defaultRowHeight?, defaultColWidth?, options, onOptionsChange?, formatValue?, onPrint?, labels?, rowLabel?, columnLabel?, headerBandPx?, footerBandPx? }

    The preview modal. A settings sidebar on the left (paper · orientation · scale · margins · header/footer · repeat rows/columns · print area · gridline/header print toggle) + a page-card scroller on the right. When formatValue is not specified, it converts column.accessor(row) to a string for rendering: the same default behavior as <XlReact>.

  • PageBreakOverlay{ pagination, rowHeights?, colWidths?, defaultRowHeight, defaultColWidth, offsetTop?, offsetLeft?, className? }

    An overlay that draws dashed page-break lines over the live grid. It is placed inside a position: relative container with pointer-events: none so that it does not block selection/scrolling.

  • startPrint(onAfter?)void

    An entry point that can be called even without the preview. Toggles the body class → window.print() → the afterprint firing or a 4-second safety timer: whichever comes first removes the class and calls onAfter (with double-invocation prevention).

  • PAPER_SIZES_MM · DEFAULT_MARGINS_MM · MM_TO_PX · DEFAULT_PRINT_OPTIONS · DEFAULT_PRINT_PREVIEW_LABELSconst

    Constants for power users. Paper presets, default margins (19mm uniform), 96dpi conversion constants, option defaults, and Korean label presets.

Preview + controller hook + direct print tsx
import { useState } from 'react';
import {
  XlReact,
  PrintPreview,
  usePrintController,
  startPrint,
} from 'hyper-xl';

function Workbook({ columns, rows }) {
  const print = usePrintController({
    initial: {
      paperSize: 'A4',
      orientation: 'portrait',
      header: { center: 'Monthly shipment statement' },
      footer: { right: '&D &T  &P / &N' },
      repeatRows: { start: 0, end: 0 },  // Repeat row 1 (the header) on every page
      filename: 'shipments.xlsx',
      sheetName: 'Sheet1',
    },
  });

  return (
    <>
      <button onClick={print.open}>Print preview</button>
      <button onClick={() => startPrint()}>Print now</button>
      <XlReact columns={columns} rows={rows} />
      <PrintPreview
        open={print.isOpen}
        onClose={print.close}
        rows={rows}
        columns={columns}
        options={print.options}
        onOptionsChange={print.update}
      />
    </>
  );
}
Page-break preview overlay tsx
import { useMemo } from 'react';
import { XlReact, PageBreakOverlay, paginate } from 'hyper-xl';

function GridWithPageBreaks({ columns, rows, options }) {
  const pagination = useMemo(
    () => paginate({
      rowCount: rows.length,
      colCount: columns.length,
      defaultRowHeight: 24,
      defaultColWidth: 100,
      options,
    }),
    [rows.length, columns.length, options],
  );
  return (
    <div style={{ position: 'relative' }}>
      <XlReact columns={columns} rows={rows} />
      <PageBreakOverlay
        pagination={pagination}
        defaultRowHeight={24}
        defaultColWidth={100}
      />
    </div>
  );
}
Resolving header·footer placeholders directly (reuse for PDF / server render) tsx
import { resolvePlaceholders, paginate } from 'hyper-xl';

const pagination = paginate({
  rowCount: 500,
  colCount: 12,
  defaultRowHeight: 24,
  defaultColWidth: 100,
  options: { paperSize: 'A4', orientation: 'landscape' },
});

pagination.pages.forEach((page) => {
  const footer = resolvePlaceholders('&F · &P / &N', {
    pageNumber: page.pageIndex + 1,
    totalPages: pagination.pages.length,
    filename: 'export.xlsx',
  });
  // → "export.xlsx · 1 / 4"
});

Cell format (font / alignment / fill / border) + editing UI

FeatureMedium The grid renders consumer-owned per-cell visual formats, and the toolbar edits them.

Characteristics

  • cellFormats is injected as a map or a function. The grid does not own the format state.
  • CellFormatToolbar is a library-provided UI that takes the selection and a writable CellFormatsMap and returns a new map via onCellFormatsChange.
  • Applies font family / size / bold / italic / underline / strikethrough / color as inline styles.
  • Renders horizontal and vertical alignment, line wrapping, indentation, background color, and top/bottom/left/right borders per cell.
  • Horizontal alignment supports 'left' / 'center' / 'right' / 'justify' (justified) / 'distributed' (distributed: the last line is evenly aligned too). Vertical supports 'top' / 'middle' / 'bottom' / 'distributed' (stretch).
  • For borders, Box, Top, Right, Bottom, and Left apply only to the outer rectangle of the selection range, and only All applies down to every cell boundary inside the range.
  • The map keys are 0-based coordinates of the current view. An app that handles row/column insertion, deletion, and reordering itself must shift/prune the map at the same time.
  • A function resolver is useful for rendering id-keyed state in O(1), but the default toolbar does not update the resolver directly.
  • For a cell that is being edited, the format styles are temporarily suppressed so that the raw value behind the editor overlay is not visible.
  • The numberFormat field converts the cell value into a display string (see number format). You edit it with the number-format dropdown and the decimal-places increment/decrement buttons in CellFormatToolbar, and it is applied at render time only for cells that have no cellRenderer.
  • Format Painter: Ctrl+Shift+C saves the active cell's format into a volatile in-memory buffer, and Ctrl+Shift+V applies it to the current selection range (values / formulas are preserved, §7.3). When the buffer is empty, paste is a no-op, and copying from a cell with no format and then pasting clears the format of the target cell (Excel behavior). Wiring on CellFormatToolbar the onFormatPainterToggle + formatPainterArmed props exposes a toolbar button with the same behavior. When cellFormats is a function resolver, the painter is a silent no-op and the browser's default shortcut works as usual.

Display and editing

import { useState } from 'react';
import {
  CellFormatToolbar,
  XlReact,
  cellFormatKey,
  type CellFormatsMap,
  type SelectionSnapshot,
} from 'hyper-xl';

const initialFormats: CellFormatsMap = {
  [cellFormatKey(0, 0)]: {
    font: { bold: true, color: '#ffffff' },
    align: { horizontal: 'center', vertical: 'middle' },
    fill: { backgroundColor: '#1f2937' },
  },
  [cellFormatKey(1, 2)]: {
    align: { horizontal: 'right' },
    border: { bottom: { style: 'double', color: '#0f172a' } },
  },
};

function Sheet() {
  const [selection, setSelection] = useState<SelectionSnapshot | null>(null);
  const [cellFormats, setCellFormats] = useState<CellFormatsMap>(initialFormats);

  return (
    <>
      <CellFormatToolbar
        selection={selection}
        cellFormats={cellFormats}
        onCellFormatsChange={setCellFormats}
      />
      <XlReact
        columns={columns}
        rows={rows}
        cellFormats={cellFormats}
        onSelectionChange={setSelection}
      />
    </>
  );
}

// Function form: it is called for every visible cell, so keep it O(1).
// The toolbar editing UI is used together with a writable CellFormatsMap.
<XlReact
  columns={columns}
  rows={rows}
  cellFormats={(rowIndex, columnIndex) =>
    rowIndex === 0 ? { font: { bold: true } } : undefined
  }
/>

API

  • cellFormatsCellFormatResolver | CellFormatsMap

    Keys are 0-based ${row}:${col}. Using the cellFormatKey helper lets you centralize string-key generation.

  • CellFormatToolbarcomponent

    Takes selection, cellFormats, and onCellFormatsChange, and applies font, alignment, fill, and border changes to the selection range. cellFormats must be a CellFormatsMap.

  • applyCellFormatPatch(formats, ranges, patch) => CellFormatsMap

    A pure utility used when applying a nullable patch to the selection area from an external UI such as the toolbar.

  • applyCellBorderPatch(formats, ranges, placement, side) => CellFormatsMap

    outline/top/right/bottom/left apply only to the outline of the selection range. all applies down to the interior gridlines of the range, but one of the cells owns the edge so that adjacent cells do not draw the same line twice.

Header / number / status / total-row formats + toolbar tsx
const initialFormats = useMemo(() => {
  const map: CellFormatsMap = {};
  columns.forEach((_, c) => {
    map[cellFormatKey(0, c)] = {
      font: { bold: true, color: '#fff' },
      align: { horizontal: 'center', vertical: 'middle' },
      fill: { backgroundColor: '#1f2937' },
    };
    map[cellFormatKey(totalRowIndex, c)] = {
      font: { bold: true },
      fill: { backgroundColor: '#fef9c3' },
      border: { top: { style: 'thick', color: '#ca8a04' } },
    };
  });
  map[cellFormatKey(2, 3)] = {
    align: { horizontal: 'center' },
    fill: { backgroundColor: '#fee2e2' },
    font: { color: '#991b1b', strikethrough: true },
  };
  return map;
}, [columns]);
const [selection, setSelection] = useState<SelectionSnapshot | null>(null);
const [formats, setFormats] = useState<CellFormatsMap>(initialFormats);

<CellFormatToolbar
  selection={selection}
  cellFormats={formats}
  onCellFormatsChange={setFormats}
/>
<XlReact
  columns={columns}
  rows={rows}
  cellFormats={formats}
  onSelectionChange={setSelection}
/>

Number Format

FeatureMedium A pure engine that converts a cell value into a display string according to an Excel format code.

Characteristics

  • formatCellValue(value, format, locale?) is a pure function that does not depend on the UI. It can be used standalone without a grid.
  • It is applied at render time only when cellFormat.numberFormat is specified and the column has no cellRenderer. A cellRenderer always takes precedence.
  • During editing it always shows the original value. The format conversion is display-only and does not change the stored value.
  • CellFormatToolbar has a built-in number format dropdown (General, Number, Currency, Accounting, Percent, Scientific, Fraction, Date, Time, Text) and increase/decrease decimal places buttons that apply format codes to the selection. The list can be replaced with the numberFormats prop.
  • It supports General / Integer / Decimal / Thousands / Currency (₩·$) / Accounting / Percent / Scientific notation / Fraction / Date and time formats.
  • It interprets 4-section custom codes 양수;음수;0;텍스트, forced zero padding (00.0), unit suffixes (0"톤"), color tokens ([Red]), and the digit placeholders 0 / # / ?.
  • Determinism contract: The group separator (,) and the decimal point (.) are fixed to ASCII regardless of locale. The locale affects only the month and weekday names, which are based on Intl.DateTimeFormat (UTC).
  • Dates accept Date objects, Excel serial numbers (relative to 1899-12-30), and ISO strings alike.
  • Values that cannot be interpreted as numbers are passed through unchanged to the text section (@).

Display and usage

import {
  XlReact,
  cellFormatKey,
  formatCellValue,
  NUMBER_FORMAT_PRESETS,
  increaseDecimals,
  decreaseDecimals,
  type CellFormatsMap,
} from 'hyper-xl';

// 1) Standalone use as a pure function
formatCellValue(1500000, '₩ #,##0');        // "₩ 1,500,000"
formatCellValue(0.3625, '0.0%');            // "36.3%"
formatCellValue('2026-05-22', 'YYYY년 MM월 DD일'); // "2026년 05월 22일"
formatCellValue(-98000, '#,##0;[Red](#,##0)');     // "(98,000)" (negative section)

// 2) Apply to a grid cell: specify numberFormat in cellFormats
const cellFormats: CellFormatsMap = {
  [cellFormatKey(1, 1)]: { numberFormat: NUMBER_FORMAT_PRESETS.CURRENCY_KRW },
  [cellFormatKey(1, 2)]: { numberFormat: '0.0%' },
};

<XlReact columns={columns} rows={rows} cellFormats={cellFormats} />

// 3) Decimal places increment/decrement helper (takes a format code and returns a format code)
increaseDecimals('#,##0');    // "#,##0.0"
decreaseDecimals('#,##0.00'); // "#,##0.0"

API

  • formatCellValue(value, format?, locale?) => string

    A pure function that converts a value into a display string according to a format code. If format is omitted it uses the General format, and the locale default is 'en-US'.

  • NUMBER_FORMAT_PRESETSRecord<NumberFormatPreset, string>

    A collection of commonly used format code constants. GENERAL, INTEGER, NUMBER, THOUSANDS, CURRENCY_KRW, CURRENCY_USD, ACCOUNTING_KRW, PERCENT, SCIENTIFIC, FRACTION, DATE_ISO, DATE_KO, TIME_HM, DATETIME, TEXT, and so on.

  • increaseDecimals / decreaseDecimals(format) => string

    Returns a new format code by increasing or decreasing the format code's decimal places by one. This is an API-level helper corresponding to Excel's “Increase/Decrease Decimal” buttons.

  • adjustFormatDecimals(format, delta) => string

    A lower-level helper that adds or subtracts decimal places by delta. increaseDecimals / decreaseDecimals wrap this function.

  • CellFormatToolbar · numberFormats{ label, value }[]

    Replaces the preset list of the toolbar's number format dropdown. value is a format code, and an empty string ('') maps to General, clearing the cell's numberFormat. If omitted, the default Korean preset list is used.

Cell Styles / Theme Presets

FeatureLow A pure model that composes named CellFormat bundles (built-in presets + user styles) onto the selection, plus a gallery toolbar. (Spec §7.7)

Characteristics

  • A cell style is a named CellFormat bundle. When applied, it is flattened into the cellFormats entries that the grid already draws, so there is no new grid state or render path (a consumer-controlled model, the same as cell formats and conditional formatting).
  • 15 built-in presets: Title and Heading / Good, Bad, and Neutral / Input, Output, and Calculation / Warning and Note / Subtotal and Total / Accent 1~3 (theme colors). They follow Excel's default theme colors. They are exposed as BUILTIN_CELL_STYLES (a map) / BUILTIN_CELL_STYLE_LIST (an order-preserving array).
  • createCellStyleRegistry(custom?) layers user styles on top of the built-in presets (for the same id, the user style takes precedence). The registry is immutable (frozen): defineCellStyle / removeCellStyle return a new registry that can go straight into React state (save / reuse).
  • The application mode of applyCellStyle: 'replace' (default: replaces the cell format with the style, the Excel behavior) · 'merge' (overwrites per facet: only adds/overwrites, never removes) · undefined or an empty format (clears to Normal). replace uses a deep clone per cell, so changing the style definition later does not change already-painted cells.
  • Applying copies the style's format, so the grid does not keep a cell-to-style link. Therefore, unlike Excel, editing a style definition does not automatically update cells that have already been applied. To propagate a change, apply it again.
  • buildTableStyleFormats (P3): Applies a header row · striped body · total row all at once to create a table style.
  • CellStyleToolbar is a controlled gallery dropdown (swatch preview) that takes selection + cellFormats. The same dropdown can be integrated into CellFormatToolbar by passing the cellStyleRegistry prop (the same fold-in pattern as the merge and conditional formatting controls).

Usage

import { useState } from 'react';
import {
  XlReact,
  CellFormatToolbar,
  createCellStyleRegistry,
  applyNamedCellStyle,
  type CellFormatsMap,
  type SelectionSnapshot,
} from 'hyper-xl';

// Built-in presets + a saved user style ("브랜드").
const registry = createCellStyleRegistry([
  {
    id: 'brand',
    label: '브랜드',
    category: 'custom',
    format: {
      fill: { backgroundColor: '#1f3864' },
      font: { bold: true, color: '#ffd966' },
      align: { horizontal: 'center' },
    },
  },
]);

function Sheet() {
  const [formats, setFormats] = useState<CellFormatsMap>({});
  const [selection, setSelection] = useState<SelectionSnapshot | null>(null);

  return (
    <>
      {/* Integrate the "셀 스타일 ▾" gallery into the format toolbar: clicking updates cellFormats. */}
      <CellFormatToolbar
        selection={selection}
        cellFormats={formats}
        onCellFormatsChange={setFormats}
        cellStyleRegistry={registry}
      />
      <XlReact
        columns={columns}
        rows={rows}
        cellFormats={formats}
        onSelectionChange={setSelection}
      />
    </>
  );
}

// Or apply directly with a pure helper, without a toolbar:
// const next = applyNamedCellStyle(formats, selection.ranges, registry, 'total');

API

  • createCellStyleRegistry(custom?: NamedCellStyle[]) => CellStyleRegistry

    Creates an immutable (frozen) registry that layers user styles on top of the built-in presets. For the same id, the user style overrides. BUILTIN_CELL_STYLE_REGISTRY is a ready-made registry containing only the presets.

  • NamedCellStyle{ id; label?; category?; builtin?; format }

    A named CellFormat bundle. id is the registry key and format is the format to apply. category is used to group gallery sections.

  • defineCellStyle / removeCellStyle(registry, …) => CellStyleRegistry

    Returns a new registry with a style added, replaced, or removed (the input is immutable). Removing an unknown id is a no-op that returns the same reference unchanged.

  • resolveCellStyle / getCellStyle / listCellStylesLookup helpers

    These return, respectively, id → CellFormat, id → NamedCellStyle, and registry → an insertion-order array (with category / builtin filter support).

  • applyCellStyle(formats, ranges, format?, options?) => CellFormatsMap

    Composes a style's CellFormat onto the selection. options.mode is 'replace' (default) / 'merge'. If format is undefined (or an empty format), it clears the formatting of the region (Normal). It never mutates the input map.

  • applyNamedCellStyle(formats, ranges, registry, id, options?) => CellFormatsMap

    A shortcut helper that resolves an id from the registry and applies it. An unknown id is a no-op that returns the input map unchanged (the same reference), so a wrong id does not clear formatting.

  • buildTableStyleFormats(formats, range, options) => CellFormatsMap

    A P3 helper that applies a header · striped body (band / bandAlt) · total row all at once to create a table style.

  • CellStyleToolbarcomponent

    An Excel-style “Cell Styles” gallery dropdown. It is a controlled component that takes selection · cellFormats · onCellFormatsChange, and you customize it with registry · applyMode · labels. The same gallery can also be integrated into CellFormatToolbar by passing cellStyleRegistry.

Conditional Formatting

FeatureLow A pure evaluator that reduces a list of rules into per-cell formats and decorations, plus a data bar / icon renderer.

Characteristics

  • evaluateConditionalFormats(rules, rows, columns, options?) is a pure function that does not depend on grid state. It compares using the original value returned by each column's accessor, so it is independent of the number-display conversion (number format) and easy to unit test.
  • The return value is { formats, decorations }. formats is a CellFormatsMap keyed by "row:col" that connects directly to the cellFormats prop.
  • Supported rules: value comparison (greater than / less than / between), top and bottom N (count or %), above and below average, duplicate and unique, text (contains / begins with / ends with), date (today / yesterday / last 7 days / this and last week / this and last month), Color Scale, data bar, icon set.
  • A color scale reduces a red→yellow→green gradient into fill.backgroundColor (the default is Excel's 3-color min / 50th percentile / max).
  • Data bars and icon sets cannot be expressed as a CellFormat, so they are separated out as decorations. They are drawn into cells by the cellRenderer created by makeConditionalCellRenderer, so the grid core is not modified.
  • Rules have their priority applied in array order (index 0 is the highest). When multiple rules apply to the same cell, the earlier rule wins per CSS property. If a stopIfTrue rule matches, no lower rules are applied to that cell.
  • Range-based rules such as top and bottom N, average, and color scale are computed over all cells of the target column as a single pool (the same as Excel's "applies to" range).
  • Date rules can be evaluated deterministically by injecting options.now (testing / SSR). The first day of the week is options.weekStartsOn (0=Sunday, 1=Monday).
  • Because makeConditionalCellRenderer has no index on the cellRenderer, it dereferences cells by row.id / column.id. Pass the same rows / columns arrays (identical order and ids) to the evaluator and the renderer. Stabilize the returned renderer and the columns built from it with useMemo. If the renderer's identity changes, new column objects are created, the per-cell memo breaks, and all decorated cells re-render.
  • Combination with number format: When a cell has a cellRenderer, the grid's numberFormat display path is bypassed. Therefore, to keep number formatting on the value next to a data bar / icon as well, pass the same cellFormats as the grid to makeConditionalCellRenderer via options.cellFormats. The value is then converted with formatCellValue and displayed identically to Cell.tsx (for example, ₩1,234,567 instead of 1234567). The priority order is options.baseRenderernumberFormat → original.
  • For range statistics (percentiles, top N, etc.), the evaluator scans all rows of the target column (not just the visible area). On large grids, cache aggressively with useMemo and, if needed, narrow the rule's applies-to range in advance.

Usage

import { useMemo } from 'react';
import {
  XlReact,
  cellFormatKey,
  evaluateConditionalFormats,
  makeConditionalCellRenderer,
  type ConditionalRule,
} from 'hyper-xl';

const rows = [
  { id: 1, data: { item: '컨테이너 A', stock: 120, rate: 92, trend: 12 } },
  { id: 2, data: { item: '컨테이너 B', stock: 45, rate: 58, trend: -4 } },
  { id: 3, data: { item: '컨테이너 C', stock: 200, rate: 76, trend: 3 } },
];

// The evaluator and the grid must share the same column array (same ids and order).
const columns = [
  { id: 'item', width: 150, accessor: (r) => r.data.item },
  { id: 'stock', width: 150, accessor: (r) => r.data.stock, dataType: 'number' },
  { id: 'rate', width: 110, accessor: (r) => r.data.rate, dataType: 'number' },
  { id: 'trend', width: 110, accessor: (r) => r.data.trend, dataType: 'number' },
];

const rules: ConditionalRule[] = [
  // Color scale: throughput as red→yellow→green.
  { type: 'colorScale', columns: ['rate'] },
  // Value comparison: throughput below 60 in bold red text (together with the color scale fill).
  { type: 'cellValue', columns: ['rate'], operator: 'lessThan', value: 60,
    format: { font: { bold: true, color: '#b91c1c' } } },
  // Data bar: stock level as an in-cell bar.
  { type: 'dataBar', columns: ['stock'], color: '#3b82f6' },
  // Icon set: trend in three bands ▼ ● ▲.
  { type: 'iconSet', columns: ['trend'], iconSet: 'triangles' },
];

function Sheet() {
  // Pure evaluator: rules + rows + columns → { formats, decorations }.
  const result = useMemo(() => evaluateConditionalFormats(rules, rows, columns), []);

  // Evaluator formats + the consumer's number format (thousands + unit suffix on the stock column).
  // They touch different cells, so they merge safely with a map spread.
  const cellFormats = useMemo(() => ({
    ...result.formats,
    ...Object.fromEntries(
      rows.map((_, r) => [cellFormatKey(r, 1), { numberFormat: '#,##0"개"' }]),
    ),
  }), [result]);

  // Data bars / icons cannot be expressed as a CellFormat → rendered via cellRenderer.
  // Passing cellFormats as well makes the value next to the bar follow the cell's numberFormat.
  const render = useMemo(
    () => makeConditionalCellRenderer(result, rows, columns, { cellFormats }),
    [result, cellFormats],
  );
  const decorated = useMemo(
    () =>
      columns.map((c) =>
        c.id === 'stock' || c.id === 'trend' ? { ...c, cellRenderer: render } : c,
      ),
    [render],
  );

  // The grid and the decoration renderer share the merged cellFormats.
  return <XlReact columns={decorated} rows={rows} cellFormats={cellFormats} />;
}

API

  • evaluateConditionalFormats(rules, rows, columns, options?) => { formats, decorations }

    A pure function that reduces a list of rules into a per-cell CellFormat map (formats) and a data bar / icon decoration map (decorations). Both are sparse maps keyed by "row:col".

  • ConditionalRuleunion

    A discriminated union of cellValue · topBottom · average · duplicate / unique · text · date · colorScale · dataBar · iconSet. Every rule shares columns? (the target column ids, all columns if omitted) and stopIfTrue?.

  • EvaluateOptions{ now?: Date; weekStartsOn?: 0 | 1 }

    The reference time and first day of the week for date rules. Injecting now makes evaluation deterministic.

  • makeConditionalCellRenderer(result, rows, columns, options?) => cellRenderer

    Creates a cellRenderer that draws the evaluator's data bar / icon decorations onto cells. A cell with no decoration passes through to its value display, so it is safe to apply to every column. You can wrap an existing renderer with options.baseRenderer, or pass options.cellFormats (the same as the grid) to apply the cell's numberFormat to the value.

  • ConditionalCellRendererOptions{ baseRenderer?; cellFormats? }

    The options for makeConditionalCellRenderer. The value display priority is baseRenderercellFormats's numberFormat → the original value, and if baseRenderer is present, cellFormats is ignored.

  • ConditionalDataBar / ConditionalIconcomponent

    A presentational component used when rendering decorations directly. It can be composed within a custom cellRenderer.

  • resolveConditionalDecoration(decorations, rowIndex, columnIndex) => ConditionalDecoration | undefined

    A helper that looks up a decoration by index (the same pattern as resolveCellFormat).

  • ConditionalFormatToolbarcomponent

    An Excel-style “Conditional Formatting” dropdown. Like CellMergeToolbar, it is a controlled component that takes selection · columns · rules · onRulesChange and adds/removes rules targeting the columns of the selection. Menu: Highlight Cells (greater than · less than · between, with value entry) · Top/Bottom · above and below average · duplicate and unique · data bar, color scale, icon set · Clear Rules (selection / all). A new rule is added to the front of the array and has the highest priority. The same dropdown can also be integrated into the format toolbar by passing conditionalRules / onConditionalRulesChange / columns to CellFormatToolbar (the same pattern as the merge controls).

  • Rule builder helpersbuild* / clear* / selectionColumnIds

    Pure helpers for building a toolbar yourself: selectionColumnIds, buildDataBarRule · buildColorScaleRule · buildIconSetRule · buildCellValueRule · buildTopBottomRule · buildAverageRule · buildDuplicateRule, appendRule, clearRulesForColumns · clearAllRules, DEFAULT_HIGHLIGHT_FORMAT.

Custom Cell Renderer

FeatureHigh A display renderer + edit renderer that draws arbitrary React elements inside a cell. Specified at two levels: column and cell.

Characteristics

  • Separation of Display and Edit: cellRenderer is display-only, and cellEditor is edit-only, entered via F2 · double-click · typing. Both are optional, and if absent they fall back to the default text display / default input editor (existing behavior unchanged).
  • Two-level specification: at the column level (Column.cellRenderer · Column.cellEditor) and the cell level (cellRenderers prop). The cell level overrides the column level.
  • cellRenderers follows the same resolver pattern as cellFormats · cellAnnotations: a sparse map keyed by "row:col", or a (rowIndex, columnIndex) => CellRenderer | undefined function.
  • Display renderer props: { value, row, column, rowIndex, columnIndex, isEditing }. However, since display renderers are never called while editing (the editing cell is cleared and the editor overlay is drawn instead), isEditing is always false for them — isEditing: true is only passed to edit renderers. The edit renderer adds { onCommit, onCancel, mode, initialDraft } to these.
  • The editor is mounted as a component (its own fiber): you can freely manage draft state with hooks such as useState. By contrast, the display renderer is called as a pure function, so do not use hooks at the top level (if you need state, render a child component). When entered by typing, that key is provided in mode='overwrite' · initialDraft. (The grid restores user-select in the editor area, so input and text selection work correctly.)
  • onCommit(next, nav?) preserves the type: the entered value (number · object · etc.) flows to onCellChange without coercion. With nav ('enter' | 'tab' | 'shift-tab' | 'shift-enter' | 'none') you control the active-cell movement after commit just like the built-in editor. If next equals the current value, no commit occurs.
  • Virtual scroll compatible: cells are compared with React.memo and skip re-rendering if the cellRenderer identity is the same. Stabilize the renderer at module scope or with useMemo. Passing a new function on every render redraws all cells. For the resolver-function form too, the pattern of returning the same reference is recommended.
  • Cancel on outside click: because the grid cannot know a custom editor's draft, clicking another cell cancels the edit (a built-in input saves on blur). To save on click-out as in Excel, call onCommit directly in the editor's onBlur. That commit is processed before the cancel.
  • A read-only column (readOnly) does not open an editor even if it has a cellEditor. A column with a validation list gives priority to the list picker. A merged region is rendered and edited only at the anchor cell.

Usage

import { useState } from 'react';
import {
  XlReact,
  type CellRendererProps,
  type CellEditorProps,
  type CellRenderers,
  type Column,
} from 'hyper-xl';

const rows = [
  { id: 1, data: { task: 'Loading', progress: 65, status: 'In progress' } },
  { id: 2, data: { task: 'Departure', progress: 100, status: 'Complete' } },
  { id: 3, data: { task: 'Standby', progress: 15, status: 'Delayed' } },
];

// Display renderer: receives value/row/column/index/editing state as props.
// Progress as a colored bar: 100%=green, below 30%=red, otherwise blue.
function ProgressBar({ value }: CellRendererProps) {
  const pct = Math.max(0, Math.min(100, Number(value) || 0));
  const color = pct >= 100 ? '#16a34a' : pct < 30 ? '#dc2626' : '#2563eb';
  return (
    <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
      <div style={{ flex: 1, height: 6, background: '#e5e7eb', borderRadius: 3 }}>
        <div style={{ width: `${pct}%`, height: '100%', background: color, borderRadius: 3 }} />
      </div>
      <span style={{ color: '#9ca3af', fontSize: 12 }}>{pct}%</span>
    </div>
  );
}

// Status badge: status string as a colored chip.
const BADGE: Record<string, string> = {
  'In progress': '#2563eb',
  'Complete': '#16a34a',
  'Delayed': '#dc2626',
};
function StatusBadge({ value }: CellRendererProps) {
  const c = BADGE[String(value)] ?? '#6b7280';
  return (
    <span style={{ color: c, background: `${c}1a`, padding: '2px 8px', borderRadius: 10, fontSize: 12 }}>
      {String(value)}
    </span>
  );
}

// Edit renderer: numeric input editor (0~100) separate from the display (bar).
// The editor is mounted as a component, so you can freely use hooks like useState.
// onCommit(next, nav?) commits the value as-is by type, onCancel cancels.
function ProgressEditor({ value, mode, initialDraft, onCommit, onCancel }: CellEditorProps) {
  const seed = mode === 'overwrite' && initialDraft ? initialDraft : String(Number(value) || 0);
  const [draft, setDraft] = useState(seed);
  const commit = (nav?: 'enter') =>
    onCommit(Math.max(0, Math.min(100, Math.round(Number(draft) || 0))), nav);
  return (
    <div
      style={{ display: 'flex', alignItems: 'center', gap: 6 }}
      onBlur={(e) => {
        if (!e.currentTarget.contains(e.relatedTarget)) commit();
      }}
    >
      <input
        type="number"
        min={0}
        max={100}
        value={draft}
        autoFocus
        onFocus={mode === 'edit' ? (e) => e.target.select() : undefined}
        onChange={(e) => setDraft(e.target.value)}
        onKeyDown={(e) => {
          if (e.key === 'Enter') commit('enter');
          if (e.key === 'Escape') onCancel();
        }}
        style={{ width: 64 }}
      />
      <span>%</span>
    </div>
  );
}

const columns: Column[] = [
  { id: 'task', width: 120, accessor: (r) => r.data.task },
  {
    id: 'progress',
    width: 220,
    accessor: (r) => r.data.progress,
    // Column-level renderer: applies to every cell in this column.
    cellRenderer: (p) => <ProgressBar {...p} />,
    cellEditor: (p) => <ProgressEditor {...p} />,
  },
  {
    id: 'status',
    width: 120,
    accessor: (r) => r.data.status,
    cellRenderer: (p) => <StatusBadge {...p} />,
  },
];

// Cell-level override: a "row:col" map or a (rowIndex, columnIndex) => renderer function.
const cellRenderers: CellRenderers = {
  '2:0': ({ value }) => <strong style={{ color: '#dc2626' }}>{String(value)}</strong>,
};

function Sheet() {
  return (
    <XlReact
      columns={columns}
      rows={rows}
      cellRenderers={cellRenderers}
      onCellChange={(c) => console.log(c.coord, c.nextValue)}
    />
  );
}

API

  • cellRenderersCellRenderers

    Cell-level display renderer. A map keyed by "row:col" or a (rowIndex, columnIndex) => CellRenderer | undefined function. When the same cell has both a column renderer and a cell renderer, the cell renderer takes precedence.

  • Column.cellRenderer / Column.cellEditorCellRenderer<T> / CellEditor<T>

    Column-level display and editing renderer. cellEditor draws the editing UI that is entered via F2, double-click, or typing.

  • CellRendererProps<T>{ value; row; column; rowIndex; columnIndex; isEditing }

    Props received by the display renderer. row and column pass the original object by reference, and isEditing indicates whether that cell is being edited.

  • CellEditorProps<T>extends CellRendererProps<T> { onCommit; onCancel; mode?; initialDraft? }

    Props received by the edit renderer. onCommit(next, nav?) commits while preserving the type (ignored if the value is the same), and onCancel() cancels editing. mode ('edit' | 'overwrite' | 'clear') and initialDraft distinguish the entry method (F2 vs typing).

  • CellRenderer<T> / CellEditor<T>(props) => ReactNode

    Function types that receive CellRendererProps and CellEditorProps respectively and return a node.

  • CellRenderersCellRenderersMap | CellRendererResolver

    The type of the cellRenderers prop. CellRenderersMap is Record<"row:col", CellRenderer>, CellRendererResolver is (rowIndex, columnIndex) => CellRenderer | undefined.

  • CellEditCommitNav'enter' | 'shift-enter' | 'tab' | 'shift-tab' | 'none'

    The second argument of onCommit. Specifies the active-cell movement direction after commit, identical to the built-in editors (default 'none').

  • cellRendererKey / resolveCellRenderer(rowIndex, columnIndex) => string · (cellRenderers, rowIndex, columnIndex) => CellRenderer | undefined

    Key builder and resolver helpers (the same pattern as cellFormatKey and resolveCellFormat). They handle both the map and function forms.

Cell merge (Merge / Unmerge / Merge & Center)

FeatureHigh The grid renders consumer-owned merge regions as a single anchor cell, and the toolbar edits them.

Characteristics

  • merges is an array of rectangular ranges (SelectionRange). The grid does not own the merge state; it only renders it.
  • Each merge region is drawn as a single top-left anchor cell that spans the entire region horizontally and vertically. Covered cells are not rendered.
  • Clicking inside a merge region selects the entire region, and the active cell is pinned to the anchor.
  • Arrow-key movement skips over merge boundaries, so the cursor escapes outside the region instead of getting trapped at the anchor.
  • Even when the anchor row scrolls off-screen, the span keeps rendering as long as it overlaps the virtualization window.
  • The Merge / Unmerge / Merge and Center controls integrate into the format toolbar when you pass merges / onMergesChange to CellFormatToolbar. If you want to use it separately, CellMergeToolbar is also provided as-is.
  • Merge & Center applies horizontal center alignment to the anchor at the same time as merging. It uses the cellFormats / onCellFormatsChange passed to CellFormatToolbar as-is.
  • By default the grid preserves the values of covered cells. To keep only the top-left and clear the rest like Excel, opt in to onMergeClearCovered and have the consumer clear the received range itself (it can also be computed with the coveredCellRanges helper). It clears only the values; the cellFormats of covered cells remain intact.
  • A single merge calls several callbacks in sequence (onMergesChangeonCellFormatsChange if centering → onMergeClearCovered). Apps that use undo should group these into a single transaction so they are reverted all at once.
  • Merge coordinates are 0-based in the current view. Apps that handle row/column insertion, deletion, or reordering themselves must shift/prune the merges array at the same time.

Display and editing

import { useState } from 'react';
import {
  CellFormatToolbar,
  XlReact,
  type CellFormatsMap,
  type SelectionRange,
  type SelectionSnapshot,
} from 'hyper-xl';

const columns = [
  { id: 'name', width: 160, accessor: (r) => r.data.name },
  { id: 'q1', width: 90, accessor: (r) => r.data.q1 },
  { id: 'q2', width: 90, accessor: (r) => r.data.q2 },
  { id: 'q3', width: 90, accessor: (r) => r.data.q3 },
];

function Sheet() {
  const [selection, setSelection] = useState<SelectionSnapshot | null>(null);
  const [rows, setRows] = useState([
    { id: 0, data: { name: '2026 Quarterly revenue', q1: '', q2: '', q3: '' } },
    { id: 1, data: { name: 'Container A', q1: 1500, q2: 1800, q3: 1650 } },
  ]);
  const [merges, setMerges] = useState<SelectionRange[]>([
    // A title banner spanning the entire first row.
    { start: { row: 0, col: 0 }, end: { row: 0, col: 3 } },
  ]);
  const [cellFormats, setCellFormats] = useState<CellFormatsMap>({
    '0:0': { align: { horizontal: 'center' }, font: { bold: true } },
  });

  // Merging like Excel keeps only the top-left value and clears the covered cells (opt-in).
  // The library preserves data by default, so the consumer clears it itself.
  // (Clears only the values. The cellFormats of covered cells remain intact.)
  const clearCovered = (ranges: SelectionRange[]) => {
    setRows((prev) =>
      prev.map((row, r) => {
        const hit = ranges.some(
          (g) => r >= g.start.row && r <= g.end.row,
        );
        if (!hit) return row;
        const data = { ...row.data };
        for (const g of ranges) {
          if (r < g.start.row || r > g.end.row) continue;
          for (let c = g.start.col; c <= g.end.col; c++) {
            const id = columns[c]?.id;
            if (id) data[id] = null;
          }
        }
        return { ...row, data };
      }),
    );
  };

  return (
    <>
      {/* Passing merges / onMergesChange integrates the merge controls into the format toolbar. */}
      <CellFormatToolbar
        selection={selection}
        cellFormats={cellFormats}
        onCellFormatsChange={setCellFormats}
        merges={merges}
        onMergesChange={setMerges}
        onMergeClearCovered={clearCovered}
      />
      <XlReact
        columns={columns}
        rows={rows}
        merges={merges}
        cellFormats={cellFormats}
        onSelectionChange={setSelection}
      />
    </>
  );
}

API

  • mergesReadonlyArray<SelectionRange>

    An array of consumer-owned merge regions. Each range is rendered as a single top-left anchor cell and the remaining cells are covered. If omitted, the merge layer is not rendered.

  • CellMergeToolbarcomponent (merges, onMergesChange, onMergeClearCovered)

    Passing merges / onMergesChange integrates the Merge / Unmerge / Merge and Center controls into the format toolbar. onMergeClearCovered returns the range of cells that the merge covers, so clearing the received range keeps only the top-left value like Excel. To use merging separately, use CellMergeToolbar with the same props.

  • coveredCellRanges(range) => SelectionRange[]

    A pure helper that returns the cells covered in a merge region, excluding the top-left anchor, as up to 2 rectangles. Use it when computing the clear range directly in a custom merge UI.

  • mergeSelection(merges, ranges) => SelectionRange[]

    A pure utility that adds a selection range as a merge region and absorbs overlapping existing merges.

  • unmergeSelection(merges, ranges) => SelectionRange[]

    Removes merge regions that intersect the selection range.

  • normalizeMerges(merges) => SelectionRange[]

    Normalizes ranges and cleans up duplicate, single-cell, and overlapping regions (first-come wins).

Cell annotation (read-only tooltip)

FeatureMedium Per-cell tooltips injected at the development layer.

Characteristics

  • Cells that have an annotation show a small triangle indicator in the top-right corner.
  • A standard tooltip on hover: enter/leave delays, close with Esc.
  • Read-only data: the grid does not provide an annotation editing UI.
  • On merge / split / row or column deletion, the consumer removes the corresponding entry from its own source.

Two forms

// Function form
<XlReact
  cellAnnotations={(rowIndex, columnIndex) =>
    rowIndex === 0 ? 'Header memo' : undefined
  }
/>

// Map form (`${row}:${col}` key)
<XlReact cellAnnotations={{ '0:1': 'Try hovering', '3:4': 'Important' }} />

The function is called on every render for each visible cell. Keep it O(1). For heavy sources, materialize it into a map form with useMemo.

API

  • cellAnnotationsCellAnnotationResolver | CellAnnotationsMap
  • annotationShowDelayMsnumber

    Tooltip show delay (default 500).

  • annotationHideDelayMsnumber

    Hide delay after the pointer leaves (default 100).

Building an annotation map from column metadata tsx
import { useMemo } from 'react';
import { XlReact, cellAnnotationKey, type CellAnnotationsMap } from 'hyper-xl';

const annotations: CellAnnotationsMap = useMemo(() => {
  const map: Record<string, string> = {};
  rows.forEach((row, rIdx) => {
    columns.forEach((col, cIdx) => {
      const note = row.data[`${col.id}__note`] as string | undefined;
      if (note) map[cellAnnotationKey(rIdx, cIdx)] = note;
    });
  });
  return map;
}, [rows, columns]);

<XlReact columns={columns} rows={rows} cellAnnotations={annotations} />;
Helper functions: cellAnnotationKey / resolveCellAnnotation ts
import { cellAnnotationKey, resolveCellAnnotation } from 'hyper-xl';

cellAnnotationKey(0, 1);                  // '0:1'
resolveCellAnnotation(map, 0, 1);         // 'Hover me' | undefined

Cell protection (read-only)

FeatureHigh Consistently enforces per-cell read-only across all editing surfaces.

Coverage

Protection is position-based (row / column index) and is a union with Column.readOnly. A protected cell blocks all of the following:

  • edit: F2, double-click, type-to-overwrite, Backspace clear
  • clear: Delete on a non-empty selection
  • paste: Ctrl+V, native paste, right-click paste
  • fill: fill handle, Ctrl+D, Ctrl+R, Ctrl+Enter
  • cut: the clear-half of Ctrl+X
  • move: a Shift+drag move whose source or target includes a protected cell
  • rowDelete / columnDelete: deleting a row / column that includes a protected cell

Formula recalculation that the consumer sends back via onCellChange is not blocked: protection only intercepts user intent. Multi-cell gestures (paste / fill) apply only to the unprotected portion, and the onProtectedAction callback reports the skipped cells.

API

  • cellProtection(rowIndex, columnIndex) => boolean

    Return true for cells whose user mutation should be rejected.

  • onProtectedAction(info: ProtectedActionInfo) => void

    { action, coords }: fired when at least 1 cell was skipped.

Protected cells are shown with a subtle striped background. The color can be overridden with --xl-react-readonly-stripe.

Header row protection + toast message tsx
<XlReact
  columns={columns}
  rows={rows}
  cellProtection={(rowIndex) => rowIndex === 0}
  onProtectedAction={(info) => {
    toast(`This cell is protected (${info.action} · ${info.coords.length})`);
  }}
/>
ProtectedAction type typescript
type CellProtectionPredicate = (rowIndex: number, columnIndex: number) => boolean;
type ProtectedAction =
  | 'edit' | 'clear' | 'paste' | 'fill' | 'cut' | 'move'
  | 'replace' | 'rowDelete' | 'columnDelete';
interface ProtectedActionInfo {
  action: ProtectedAction;
  coords: ReadonlyArray<CellCoord>;
}

Selection aggregation

FeatureMedium Real-time SUM / AVG / COUNT in the bottom-left status bar.

Behavior

  • Renders only when the selection covers 2 or more cells.
  • SUM / AVG ignore non-numeric cells.
  • The AVG denominator excludes empty cells.
  • COUNT is identical to Excel COUNTA (non-empty cells).
  • MIN / MAX are precomputed and exposed: use them in a custom readout.

API

  • showSelectionStatsboolean
  • selectionStatsLocalestring | string[]

    BCP-47 locale (e.g. 'ko-KR').

Building a custom status bar with computeAggregates tsx
import { useMemo, useState } from 'react';
import {
  XlReact,
  computeAggregates,
  type SelectionSnapshot,
} from 'hyper-xl';

const [selection, setSelection] = useState<SelectionSnapshot | null>(null);

const aggregates = useMemo(
  () => computeAggregates(selection?.ranges ?? [], rows, columns),
  [selection, rows, columns],
);

<XlReact
  columns={columns}
  rows={rows}
  showSelectionStats={false}
  onSelectionChange={setSelection}
/>
<footer>
  SUM={aggregates.sum?.toLocaleString('ko-KR')} ·
  AVG={aggregates.avg?.toFixed(2)} ·
  COUNT={aggregates.count}
</footer>

Data validation (dropdown)

FeatureHigh Link a column to a named list to select values from a dropdown in cells.

Characteristics

  • Define named lists with validationLists and link them to a column with Column.validation.listKey. The list data is owned by the consumer and the grid only reads it.
  • Items support both strings ('활성') and objects ({ value, label }): an object shows the label in the dropdown but stores the value (e.g. a code) in the cell.
  • A ▾ caret is shown on active list cells, and when there are more than 8 items a search box appears automatically (partial match on label and value, case-insensitive).
  • Selecting a value keeps the cell selection in place (same as Excel: no automatic move). The commit flows through onCellChange, and is recorded on the undo stack only when the value actually changed.
  • When validation.strict is true, any non-empty value not in the list is shown with the invalid style. Empty values are handled by required.
  • In protected cells (Column.readOnly / cellProtection) the caret is hidden, and attempting to open fires onProtectedAction.

Open the dropdown

  • Click the ▾ caret on the active list cell
  • Alt +
  • F2 or double-click: for a list column the dropdown opens instead of the text editor.
  • Once open: / to move (stops at the ends) · Enter to select · Esc or an outside click to close.

API

  • validationListsRecord<string, ValidationList>

    listKey → list. A list is an array of strings or { value, label } items.

  • Column.validation{ listKey: string; strict?: boolean }

    Binds a column to a list. strict marks values outside the list as invalid.

Make the status / departure port columns dropdowns tsx
const columns = [
  { id: 'name', accessor: (r) => r.data.name },
  {
    id: 'status',
    accessor: (r) => r.data.status,
    validation: { listKey: 'statusList', strict: true },
  },
  {
    id: 'port',
    accessor: (r) => r.data.port,
    validation: { listKey: 'portList' }, // Object list
  },
];

<XlReact
  columns={columns}
  rows={rows}
  onCellChange={applyChange}
  validationLists={{
    statusList: ['Active', 'Under review', 'On hold', 'Discontinued'],
    // Store label in the dropdown, value (code) in the cell
    portList: [
      { value: 'KRPUS', label: 'Port of Busan (KRPUS)' },
      { value: 'JPTYO', label: 'Port of Tokyo (JPTYO)' },
    ],
  }}
/>
Handle validation / filtering yourself with the list helpers typescript
import {
  resolveColumnList,
  filterOptions,
  isValueInList,
} from 'hyper-xl';

// column + validationLists → ResolvedValidationOption[] | null
// (null if it is not a list cell)
const options = resolveColumnList(column, validationLists);

// Partial-match filter identical to the search box (label · value, case-insensitive)
const matches = filterOptions(options ?? [], 'kr');

// Same rule as strict validation (empty values always pass)
const ok = isValueInList('KRPUS', options ?? []);

Formula Engine (Formula Engine: arithmetic + cell references)

FeatureMedium Evaluates cell references and arithmetic such as =A1+B1·=A1*B1/2, and automatically recalculates when a dependent cell changes.

Characteristics

  • Pure-function tokenizer → parser → evaluator plus a FormulaSheet helper. The grid is display-only; when the consumer passes raw input to the sheet via onCellChange, the sheet follows the dependency graph and updates the results.
  • Supported syntax: integers / decimals, unary sign (-A1), arithmetic (+ − * /), parentheses, and A1-style relative/absolute cell references (including multi-character columns: AA1, AB10, $A$1, $A1, A$1).
  • Auto-fill (drag · Ctrl+D · Ctrl+R · Ctrl+Enter) automatically shifts the relative part of a cell reference by the offset, while the absolute part marked with $ stays unchanged. This matches Excel's behavior. The library exposes the same transformation externally through shiftFormulaRefs as well.
  • Error codes: #DIV/0! (division by zero), #CIRCULAR! (circular reference), #REF! (invalid reference), #VALUE! (non-numeric operand), #NAME? (unsupported identifier or function: planned for expansion in P2).
  • Cycle detection is handled by Kahn topological sort. Every cell in a cycle is marked with #CIRCULAR!, and breaking the cycle restores normal values. Because there is no recursion, dependency chains thousands of levels deep do not blow up the call stack.
  • Empty cells are treated as 0 in arithmetic, and numeric strings are converted automatically ("5" → 5). Label strings propagate as #VALUE!.
  • Ranges (A1:B5) and sheet functions (such as SUM) are out of scope for this stage, and are rejected as #NAME? when entered.

API

  • FormulaSheetclass

    Use setRaw(coord, raw) to store a raw value in a cell, and getRaw(coord) / getDisplay(coord) to read the raw and display values. setRaw returns the array of coordinates affected by the change.

  • parseFormula(input: string) => FormulaAst | FormulaParseError

    Works regardless of a leading =. Absolute references such as $A$1 are supported. Unsupported input such as function calls or ranges is rejected as FormulaParseError ('#NAME?' / '#REF!').

  • evaluateAst(ast, resolveRef) => number | FormulaErrorCode

    Fetches dependent cell values via resolveRef(coord) for evaluation. Error codes propagate transitively.

  • extractRefs(ast) => { row, col }[]

    Lists the cell coordinates referenced by the AST: used to build the dependency graph.

  • a1ToCoord / coordToA1A1 ↔ {row,col} conversion

    'B3'{ row: 2, col: 1 }. Allows both multi-character columns and the $ absolute marker (a1ToCoord ignores the marker and returns only the coordinate). With coordToA1(row, col, { rowAbsolute, colAbsolute }) you can output it with $ attached.

  • parseA1(ref: string) => ParsedCellRef | null

    Decomposes an A1 reference into { row, col, rowAbsolute, colAbsolute }: use it when you need to preserve the $ marker.

  • shiftFormulaRefs(formula, deltaRow, deltaCol) => string

    Shifts the relative cell references in a formula string by (deltaRow, deltaCol) and leaves absolute references marked with $ unchanged. The library uses it internally during auto-fill, and the same transformation can also be called externally.

  • isFormulaError(value) => value is FormulaErrorCode

    Guards the five Excel error literals (FORMULA_ERROR_CODES).

Wire FormulaSheet into onCellChange tsx
import { useMemo, useRef, useState } from 'react';
import { FormulaSheet, XlReact } from 'hyper-xl';

// Consumer-owned. The sheet holds raw + evaluation results + the dependency graph.
const sheetRef = useRef<FormulaSheet | null>(null);
if (!sheetRef.current) {
  const s = new FormulaSheet();
  s.setRaw({ row: 1, col: 1 }, 12);          // B2
  s.setRaw({ row: 1, col: 2 }, 1500);        // C2
  s.setRaw({ row: 1, col: 3 }, '=B2*C2');    // D2 → 18000
  sheetRef.current = s;
}

const [rows, setRows] = useState(buildRowsFromSheet(sheetRef.current));

const columns = useMemo(() => (
  // Cell display uses the sheet's computed value, editing uses the raw text: double-click and you see =B2*C2.
  cols.map((c, i) => ({
    ...c,
    cellRenderer: ({ rowIndex, columnIndex }) =>
      String(sheetRef.current?.getDisplay({ row: rowIndex, col: columnIndex }) ?? ''),
  }))
), []);

return (
  <XlReact
    columns={columns}
    rows={rows}
    onCellChange={(change) => {
      sheetRef.current?.setRaw(change.coord, change.nextValue as string | number | null);
      setRows(buildRowsFromSheet(sheetRef.current!));
    }}
  />
);
Use the pure functions alone: call the evaluator without any UI typescript
import { parseFormula, evaluateAst } from 'hyper-xl';

const ast = parseFormula('=A1+B1*2');
if ('type' in ast && ast.type === 'error') {
  // ast.code === '#NAME?', etc.
} else {
  const values = new Map([['0:0', 5], ['0:1', 7]]);
  const result = evaluateAst(ast, ({ row, col }) =>
    values.get(`${row}:${col}`) ?? null,
  );
  // result === 19
}
$ absolute references + relative-reference shifting during auto-fill typescript
import { shiftFormulaRefs } from 'hyper-xl';

// Fill =B3*C3*(1+$B$2) down one cell: the absolute reference stays fixed,
// while the relative references shift by row +1.
shiftFormulaRefs('=B3*C3*(1+$B$2)', 1, 0);
// → '=B4*C4*(1+$B$2)'

shiftFormulaRefs('=$A$1+B1', 5, 2);
// → '=$A$1+D6'

// The auto-fill engine (projectFill / fillDownWithinSelection, etc.) uses the
// same function internally. The consumer does not need to call it separately,
// but it can be used when an arbitrary transformation is needed.

Formula Bar (Formula Bar)

FeatureMedium A standalone component with the same look · behavior as Excel's formula bar. It bundles the name box · fx · input field · ✓/✗ buttons into a single row that can be docked right above the grid.

Characteristics

  • It is a controlled component. You inject activeRef (an A1 string) · value (the raw formula or literal) from the outside, and receive the value the user confirmed via onCommit(next) to reflect it in the sheet model (such as FormulaSheet.setRaw).
  • The internal draft string is kept inside the component, so the parent is not re-rendered on every keystroke. When the value prop changes (external paste · undo, etc.), the draft is overwritten only when not editing.
  • Enter / ✓ / focus loss → commit, Esc / ✗ → cancel. As in Excel, losing focus is also treated as a confirm.
  • Korean·Chinese·Japanese IME composition guard: an Enter between compositionstartcompositionend is not treated as a commit, and nativeEvent.isComposing · keyCode === 229 are also checked together.
  • The name box becomes editable only when onNavigate is wired up, and only for valid A1 references (such as 'C5', '$B$2') does it pass (row, col, ref). Invalid input snaps back to activeRef.
  • readOnly locks both the formula input and the name box to prevent entering edit mode and committing while the component still renders (Enter on a focused read-only input does not commit either), whereas disabled grays it out + blocks interaction.
  • v1 limitation: real-time two-way synchronization with the in-progress draft of the grid's CellEditor is not supported. The bar's value updates only after the cell edit is committed. (Sharing the CellEditor draft store is follow-up work.)

Props

  • activeRefstring | null

    The A1 reference of the active cell. Derived via coordToA1(active.row, active.col) from the SelectionSnapshot. When null, the name box is empty and the input field also renders empty.

  • valuestring | number | null

    The raw input value of the active cell: '=A1*B1' for a formula, or a number or string for a literal. Derived from FormulaSheet.getRaw(active) or a row-data accessor.

  • onCommit(value: string | null) => void

    Called on Enter · ✓ · focus loss. An empty string is passed as null, so it can be forwarded straight to FormulaSheet.setRaw.

  • onCancel() => void

    On Esc · ✗. The draft is restored to value.

  • onNavigate({ row, col, ref }) => void

    Called when an A1 reference is entered in the name box and Enter is pressed. When unset, the name box is display-only (readOnly).

  • readOnly · disabledboolean

    readOnly blocks commits, disabled is visually inactive + blocks interaction. It works well together with the sheet protection (protected) state.

  • labelsFormulaBarLabels

    nameBox · formulaInput · commit · cancel · fxIcon · emptyPlaceholder. The defaults are Korean ('이름 상자' · '수식 입력줄' · '입력' · '취소' · 'fx').

Wire into SelectionSnapshot + FormulaSheet tsx
import { useRef, useState } from 'react';
import {
  FormulaBar,
  FormulaSheet,
  XlReact,
  coordToA1,
  type SelectionSnapshot,
} from 'hyper-xl';

// The sheet instance held by the consumer (ref for reference stability).
const sheetRef = useRef<FormulaSheet | null>(null);
if (!sheetRef.current) sheetRef.current = new FormulaSheet();

const [selection, setSelection] = useState<SelectionSnapshot | null>(null);

const active = selection?.active ?? null;
const activeRef = active ? coordToA1(active.row, active.col) : null;
const value = active ? sheetRef.current.getRaw(active) : null;

return (
  <>
    <FormulaBar
      activeRef={activeRef}
      value={value}
      onCommit={(next) => {
        if (!active) return;
        sheetRef.current!.setRaw(active, next);
        // Since the sheet's display values were updated, rebuild the grid row data as well.
        setRows(buildRowsFromSheet(sheetRef.current!));
      }}
    />
    <XlReact
      columns={columns}
      rows={rows}
      onSelectionChange={setSelection}
      onCellChange={(c) => {
        sheetRef.current!.setRaw(c.coord, c.nextValue as string | number | null);
        setRows(buildRowsFromSheet(sheetRef.current!));
      }}
    />
  </>
);
Wire navigation into the name box tsx
// When the consumer can control the active cell (e.g. its own selection model),
// it receives onNavigate and jumps via its own reducer.
<FormulaBar
  activeRef={activeRef}
  value={value}
  onCommit={handleCommit}
  onNavigate={({ row, col }) => selectionDispatch({ type: 'moveTo', row, col })}
/>

Import / Export (Excel · CSV · TSV)

FeatureHigh Serializes a grid snapshot to .xlsx or RFC 4180 CSV / TSV, and absorbs the same formats back into the grid. ExcelJS is lazy-loaded via await import('exceljs'), so the main bundle stays free of the dependency.

Characteristics

  • Snapshot-based API: the helpers take only the GridSnapshot assembled by the consumer (rows·columns· optional cellFormats·merges). Because they do not peek into the grid component's internal state, you can export at any point in any shape.
  • Lazy loading: exceljs sits in peerDependencies (optional) and is also externalized in the bundler configuration. If you use only CSV / TSV, ExcelJS does not need to be installed at all.
  • Symmetric round-trip: the export default is includeHeader: false (the common pattern where the consumer's row 0 already holds the caption row), and the import default is dropHeaderRow: false, so the header row is included in the data too and a visually identical grid is restored. headerRow is used only to extract column ids.
  • Format preservation: font (bold · italic · underline · color), fill (background color), alignment (horizontal · vertical), borders (4 sides + diagonal), number format, column width, and merged cells are mapped two-way (cellFormatToExcelStyle / cellFormatFromExcelStyle). The header automatically gets bold + a dark background + a 1-row freeze (which you can turn off with headerStyle / freezeHeader).
  • Formula round-trip: if a cell value is a string that starts with =…, it is written as an actual Excel formula cell on export (recalculated when Excel opens the file), and on import it is restored verbatim as the same string. Combined with FormulaSheet, the display values are evaluated live too.
  • Multi-sheet export: exportMultiSheetXlsx serializes several snapshots into one workbook. If sheetName is omitted, Sheet1·Sheet2… is assigned automatically, and duplicates are resolved with a _2·_3 suffix.
  • File-size guard: importFromXlsx rejects input exceeding maxFileSizeBytes (default DEFAULT_MAX_IMPORT_SIZE_BYTES = 50 MB) as ImportFileTooLargeError before parsing. Setting it to 0 disables the guard.
  • Validation reporting: ImportResult.warnings accumulates per-kind messages such as empty-sheet·sheet-not-found· header-missing·duplicate-header· cell-error (#REF! / #VALUE!, etc.)· merge-skipped. <ImportDialog> groups them by kind and shows a count and a sample message.
  • Column-mapping UI: in the dialog's "column mapping" table you can edit the target Column.id for each source header. The result objects are renamed to the new ids at confirmation time and passed to the consumer. Programmatically, you can pass the mapping directly to the ImportOptions.columnMapping (or CsvOptions.columnMapping) option.
  • CSV / TSV: RFC 4180 quoting / escaping + BOM stripping + automatic delimiter detection (, · \t · ; · |) + leading-zero preservation ("012" is not converted to a number). It works without ExcelJS.

API

  • exportToXlsx (snapshot: GridSnapshot, options?: ExportOptions) => Promise<Blob>

    Returns a single-sheet .xlsx Blob. Trigger the download with triggerBlobDownload.

  • exportMultiSheetXlsx (entries: MultiSheetEntry[]) => Promise<Blob>

    Bundles several snapshots sheet by sheet into one workbook. Each entry takes a snapshot together with ExportOptions (per-sheet sheetName·range·columnIds, etc.).

  • importFromXlsx (source: File | Blob | ArrayBuffer | Uint8Array, options?: ImportOptions) => Promise<ImportResult>

    Converts an ExcelJS workbook into an ImportResult. If the size exceeds options.maxFileSizeBytes (default 50 MB), it throws ImportFileTooLargeError.

  • exportToCsv / importFromCsv (snapshot, CsvOptions?) => string · (text, CsvOptions?) => ImportResult

    An RFC 4180 serializer that works without ExcelJS. It supports delimiter · newline · bom · includeHeader · columnMapping.

  • buildWorkbookFromSnapshot / parseWorkbookToSnapshot The 'hyper-xl/exceljs' subpath

    A low-level path that works directly with an ExcelJS Workbook instance: use it when you manage lazy loading yourself, or assemble a workbook by combining several libraries. It is separated into its own subpath so that ExcelJS types do not leak out of the root entry's type declarations, so import it in the form import { buildWorkbookFromSnapshot } from 'hyper-xl/exceljs'.

  • cellFormatToExcelStyle / cellFormatFromExcelStyle The 'hyper-xl/exceljs' subpath (CellFormat ↔ ExcelJS.Style)

    Two-way conversion. It covers font · fill · alignment · borders · number format. It lives in the same subpath as the two helpers above and requires ExcelJS to be installed.

  • defaultExportFilename (prefix?, now?) => string

    <prefix>_YYYYMMDD_HHmm.xlsx (local time): the default prefix is xl-react.

  • triggerBlobDownload (blob: Blob, filename: string) => void

    A download trigger that works only in the browser. In non-browser environments it is a no-op.

  • ExportButton React component

    Takes rows·columns· optional cellFormats·merges and downloads an .xlsx on click. It manages the busy state automatically and reports failures via onError.

  • ImportDialog React component

    File selection / drag-and-drop → choose the sheet · header row + edit the column mapping + preview the first 10 rows + grouped validation report → on confirm it calls onImport(result, file). The labels can be overridden via DEFAULT_IMPORT_DIALOG_LABELS wholesale or by key.

ExportButton + ImportDialog: the same pattern as the demo tsx
import { useState } from 'react';
import {
  ExportButton,
  ImportDialog,
  defaultExportFilename,
  type ImportResult,
} from 'hyper-xl';

function Page() {
  const [columns, setColumns] = useState(initialColumns);
  const [rows, setRows] = useState(initialRows);
  const [cellFormats, setCellFormats] = useState({});
  const [open, setOpen] = useState(false);

  const onImport = (result: ImportResult /*, file */) => {
    // Replaces the entire grid with new data. The mapping / warnings are inside result.
    setColumns(result.columns);
    setRows(result.rows);
    setCellFormats(result.cellFormats);
  };

  return (
    <>
      <ExportButton
        rows={rows}
        columns={columns}
        cellFormats={cellFormats}
        filename={() => defaultExportFilename('my-report')}
      />
      <button onClick={() => setOpen(true)}>Import…</button>
      <ImportDialog open={open} onClose={() => setOpen(false)} onImport={onImport} />
    </>
  );
}
Programmatic: use functions only typescript
import {
  exportToXlsx,
  importFromXlsx,
  exportToCsv,
  importFromCsv,
  triggerBlobDownload,
  defaultExportFilename,
} from 'hyper-xl';

// xlsx: creates a Blob in memory, then triggers a browser download
const blob = await exportToXlsx(
  { rows, columns, cellFormats, merges },
  { sheetName: 'Orders', includeHeader: false },
);
triggerBlobDownload(blob, defaultExportFilename('order'));

// xlsx: receives a File and parses it (the 50MB guard is applied automatically)
const result = await importFromXlsx(file, {
  sheetName: 'Orders',
  headerRow: 1,
  columnMapping: { 'Name': 'name', 'Quantity': 'qty' },
});

// CSV: a pure function without ExcelJS
// exportToCsv does not write a header by default (includeHeader defaults to false),
// while importFromCsv treats the first row as a header by default (includeHeader defaults to true).
// To make the round-trip line up, set includeHeader explicitly on both sides.
const csv = exportToCsv({ rows, columns }, { delimiter: ',', bom: true, includeHeader: true });
const csvResult = importFromCsv(csv, { includeHeader: true });
Multiple sheets + formula round-trip typescript
import { exportMultiSheetXlsx } from 'hyper-xl';

// If you put a formula string such as `=B{r}*C{r}` in each row, on export it is
// written as an actual Excel formula cell. Reading it back in via import preserves
// the `=…` string as is, so combining it with FormulaSheet enables live evaluation.
const order = [
  { id: 0, data: { name: 'Name', qty: 'Quantity', price: 'Unit price', total: 'Total' } },
  { id: 1, data: { name: 'A', qty: 12, price: 1500, total: '=B2*C2' } },
  { id: 2, data: { name: 'B', qty: 8,  price: 2300, total: '=B3*C3' } },
];

const blob = await exportMultiSheetXlsx([
  { snapshot: { rows: order, columns }, sheetName: 'Orders' },
  { snapshot: { rows: inventory, columns }, sheetName: 'Inventory' },
]);

Pivot table

FeatureHigh PRD §10A: an MVP that includes a drag & drop builder for the four areas Row / Column / Value / Filter, 9 aggregations, 13 value display formats in total (P1 6: normal / % of grand total / % of column / % of row / % of parent row / % of parent column + §10A.4 P2 7 calculation modes: difference / % difference / running total / % running total / ascending·descending rank / index), grouping on row and column chips (date: year/quarter/month/week/day · number: arbitrary intervals), grand total rows and columns, a single pivot refresh, and pivot charts (bar · line · pie). The result table is rendered with the library's standard <XlReact> grid, so it uses the grid's full UX as is: cell selection · keyboard navigation · clipboard · zoom · Freeze, and more.

Components

  • PivotBuilder: a complete component that bundles the 4-area drag & drop UI and the result grid into a single component. The "Pivot table" tab on the demo page uses exactly this.
  • buildPivot(rows, config): a pure function. It returns a PivotResult. Use it when you build your own UI or feed only the result into another grid / chart.
  • pivotResultToGrid(result, labels?): converts a PivotResult into a { columns, rows, merges, freezeRowCount, freezeColCount } snapshot that can be passed straight to <XlReact>. The header's colSpan / rowSpan are mapped to the grid's merges, and the numbers in value cells are automatically formatted with Intl.NumberFormat (up to 4 decimal places).
  • pivotAggregate(kind, values): a single dispatcher for the 9 aggregations (sum · count · countA · average · max · min · stdDev · variance · product). Non-numeric values (NaN, ±Infinity, objects, boolean) are quietly ignored without polluting the bucket, and variance/stdDev are computed with the Welford algorithm as a sample variance (n − 1) form.

PivotConfig shape

  • rowsPivotField[]

    Row axis grouping keys. The order is exactly the depth of the tree: the first is the top-level group.

  • columnsPivotField[]

    Column axis grouping keys. Symmetric to rows.

  • valuesPivotValueField[]

    The aggregation targets. Each item is { key, label?, agg }, and you can add the same key multiple times to compute different aggregations at once (e.g. sum · average).

  • filtersPivotFilterField[]

    A whitelist filter applied before grouping. If you leave selectedValues empty, that field passes through unfiltered.

  • showGrandTotalRowboolean (default true)

    When rows.length === 0 it is disabled automatically: if you draw another grand total row on an axis that has only one row, the body simply appears duplicated. The same applies to columns.

  • showGrandTotalColumnboolean (default true)

The grand total is not the sum of the cells

The grand total row / column is not computed as the sum of the body cells. It always re-runs aggregate over the entire set of original rows. average· min·max·variance·stdDev· product are not distributive, so summing the cells produces an incorrect value. This is the reason the grand total is computed correctly even at positions where the cell is empty (matrix[r][c] === null).

Headless usage: getting only the result with buildPivot tsx
import { buildPivot, type PivotConfig } from 'hyper-xl';

const shipments = [
  { id: 1, data: { region: 'Busan', product: 'Container', qty: 10 } },
  { id: 2, data: { region: 'Busan', product: 'Bulk',     qty:  5 } },
  { id: 3, data: { region: 'Incheon', product: 'Container', qty: 30 } },
  { id: 4, data: { region: 'Incheon', product: 'Bulk',     qty:  7 } },
];

const config: PivotConfig = {
  rows:    [{ key: 'region' }],
  columns: [{ key: 'product' }],
  values:  [{ key: 'qty', agg: 'sum' }],
  filters: [],
};

const result = buildPivot(shipments, config);

// result.matrix         → [[10, 5], [30, 7]]
// result.rowPaths       → [['Busan'], ['Incheon']]
// result.columnPaths    → [['Container'], ['Bulk']]
// result.grandTotalRow  → [40, 12]    // Container / Bulk totals
// result.grandTotalColumn → [15, 37]  // Busan / Incheon totals
// result.grandTotal     → [52]        // overall total: recomputed from the source
PivotBuilder: using the drag & drop UI as is tsx
import { PivotBuilder, type PivotAvailableField, type Row } from 'hyper-xl';

const rows: Row[] = /* ... original rows ... */;

const fields: PivotAvailableField[] = [
  { key: 'region',  label: 'Discharge port',  type: 'text' },
  { key: 'product', label: 'Variety',    type: 'text' },
  { key: 'team',    label: 'Department',    type: 'text' },
  { key: 'qty',     label: 'Quantity',    type: 'number' },
  { key: 'weight',  label: 'Shipment volume',  type: 'number' },
];

export function PivotDemo() {
  return (
    <PivotBuilder
      rows={rows}
      availableFields={fields}
      // Optional: you can persist the layout to an external store via onConfigChange.
      onConfigChange={(config) => console.log(config)}
    />
  );
}
Rendering <XlReact> directly with pivotResultToGrid tsx

When you want to build your own sidebar and display only the result in the standard grid. Just reuse the adapter that PivotBuilder uses internally.

import { XlReact, buildPivot, pivotResultToGrid } from 'hyper-xl';

const result   = buildPivot(rows, config);
const snapshot = pivotResultToGrid(result, {
  grandTotalRow:    'Grand total',
  grandTotalColumn: 'Grand total',
});

return (
  <XlReact
    columns={snapshot.columns}
    rows={snapshot.rows}
    merges={snapshot.merges}
    freezeRowCount={snapshot.freezeRowCount}
    freezeColCount={snapshot.freezeColCount}
    readOnly
  />
);

Value display formats (§10A.4 P1)

Each Value chip has a display format dropdown next to the aggregation function, so you can re-express the same aggregation in 6 modes. Every % mode divides the numerator by the denominator for that mode, and when the denominator is null or 0, the cell becomes empty (null). The grid draws % mode cells with Intl.NumberFormat({ style: 'percent', maximumFractionDigits: 2 }).

  • normalDefault

    The original aggregation as is.

  • percentOfTotal% of grand total

    cell / overall grand total. The grid's grand total cell becomes 100%.

  • percentOfColumn% of column

    cell / the corresponding column total. The grand total row per column is 100%.

  • percentOfRow% of row

    cell / the corresponding row total. The grand total column per row is 100%.

  • percentOfParentRow% of parent row

    In a multi-level row pivot, cell / parent group total. At the top-level row the parent equals the entire dataset, so the result is identical to the ratio against the per-column total.

  • percentOfParentColumn% of parent column

    The symmetric counterpart on the column axis. It is meaningful for multi-level columns.

The engine keeps the original aggregation in matrix / grandTotal* as is, while it separately fills the display-oriented transform into displayMatrix / displayGrandTotal*. Headless users read the original and the grid reads display*, so the two perspectives coexist simultaneously in the same PivotResult.

Value display format usage example tsx
import { buildPivot, type PivotConfig } from 'hyper-xl';

const config: PivotConfig = {
  rows:    [{ key: 'region' }],
  columns: [{ key: 'product' }],
  values: [
    // You can display the same field as both raw + % representations at once.
    { key: 'qty', agg: 'sum', valueDisplay: 'normal' },
    { key: 'qty', agg: 'sum', valueDisplay: 'percentOfTotal', label: 'qty share' },
  ],
  filters: [],
};

const result = buildPivot(rows, config);
// result.displayMatrix[0]  → [30, 30/87, 5, 5/87]   // v=0 raw, v=1 ratio
// result.displayGrandTotal → [87, 1]                // the total in % mode is 100%

Grouping: date units · number intervals (§10A.5 P1)

Chips in the row / column areas have a icon that opens a grouping popover on right-click (or click). The popover shows a different UI depending on the field type:

  • date fieldPivotDateGroupUnit

    A year / quarter / month / week / day radio group. It rounds the value down to the calendar boundary of the selected unit (year → January 1, quarter → the 1st of the quarter's starting month, week → the Monday of that ISO week, day → midnight). The labels are in the 2026 · 2026-Q1 · 2026-05 · 2026-W22 · 2026-05-27 formats.

  • number fieldPivotNumberBin

    A size width + an optional origin start point. Values fall into the half-open interval [origin + k·size, origin + (k+1)·size). The labels are 0~100, 100~200, …

A chip with grouping enabled shows a small badge next to the label, such as [Quarter] or [10]. Values that cannot be coerced (empty cells, strings that are not in ISO format, etc.) are not grouped and instead fall into the existing (blank) or original-value bucket, so grouped and non-grouped rows can coexist in the same result.

The engine normalizes values into buckets at the internal readField stage (bucketize, a private implementation), so all subsequent paths (header tree · parent bucket · % mode denominator) work without change. That is, even on top of rows grouped by quarter × columns grouped by quarter, a display format like percentOfParentRow from §10A.4 still keeps its meaning.

Grouping usage example tsx
import { buildPivot, type PivotConfig } from 'hyper-xl';

const config: PivotConfig = {
  rows: [
    // Group the raw values (Date or ISO string) of the shipDate field by quarter.
    { key: 'shipDate', grouping: { dateUnit: 'quarter' } },
  ],
  columns: [{ key: 'region' }],
  values: [{ key: 'qty', agg: 'sum' }],
  filters: [],
};

// result.rowHeaders[0]  → [{ label: '2026-Q1', ... }, { label: '2026-Q2', ... }, ...]
// result.matrix         → the qty sum of quarter × region

// Number field: bins of 100
const binConfig: PivotConfig = {
  rows: [{ key: 'weight', grouping: { numberBin: { size: 100 } } }],
  columns: [],
  values: [{ key: 'qty', agg: 'count' }],
  filters: [],
};
// result.rowHeaders[0]  → [{ label: '0~100' }, { label: '100~200' }, ...]

Sort · filter (§10A.6 P1)

Chips in the row / column areas have a icon that, when clicked, opens the sort · filter popover. The popover has three sections:

  • Sort (per-field)PivotFieldSort

    Label ascending/descending (label-asc / label-desc), value-based ascending/descending (value-asc / value-desc: choose the value field index), and a manual custom order. The custom mode is applied by moving the currently displayed group keys up and down with the ▲ / ▼ buttons. null aggregations are always sorted last regardless of the sort direction (Excel behavior).

  • Label filter (per-field)PivotLabelFilter

    Four variants: include (select the explicitly allowed keys with checkboxes), text (contains / starts with / ends with / equals / does not equal), number (gt / lt / gte / lte / equals / notEquals / between), date (before / after / on / notOn / between). The label filter is applied per node of the group tree, so a parent node whose children are all trimmed away is removed as well.

  • Value filter (axis-level)PivotValueFilter

    Applied to the entire row or column axis via rowValueFilter / columnValueFilter. There are four kinds: the top / bottom direction of topN together with n, aboveAverage / belowAverage (based on the axis average), and threshold (numeric comparison). Top-N keeps all ties at the boundary value (Excel behavior).

A chip with sort · filter applied shows a small badge (↑ / ↓ / ⇅ / ⚑) next to the label. Row/column leaves trimmed away by the label filter and value filter are dropped from the header, and the denominators of the grand total and % display modes are recomputed to sum only the visible data. That is, in the "above average + % of grand total" combination, filtered rows are excluded from the denominator as well.

Sort · filter usage example tsx
import { buildPivot, type PivotConfig } from 'hyper-xl';

const config: PivotConfig = {
  rows: [
    {
      key: 'region',
      // Sort by descending value sum
      sort: { mode: 'value-desc', valueFieldIndex: 0 },
      // Only labels that contain 'Busan'
      labelFilter: { kind: 'text', op: 'contains', pattern: '부산' },
    },
  ],
  columns: [{ key: 'product' }],
  values: [{ key: 'qty', agg: 'sum' }],
  filters: [],
  // Apply an above-average filter across the entire row axis
  rowValueFilter: { kind: 'aboveAverage', valueFieldIndex: 0 },
};

// rowHeaders[0]      → regions above the average are shown sorted by sum(qty) in descending order
// grandTotal / displayMatrix → recomputed using only the data of the surviving rows

Layout Options (§10A.7 P1)

PivotConfig.layout controls the subtotal position, report format, and empty cell display value in an Excel-compatible way. They are exposed as three dropdowns at the top right of the PivotBuilder panel.

  • subtotalPosition'top' | 'bottom' | 'none'

    When the row axis has two or more levels, a subtotal row is automatically inserted for each non-leaf group. top places it above the start of the group, bottom places it after the last leaf, and none hides it visually. Because the engine always computes them via PivotResult.rowSubtotals, chart/export consumers can read the subtotals regardless of the position setting.

  • reportLayout'compact' | 'outline' | 'tabular'

    tabular (default): each leaf row repeats all of its parent labels. outline: each row field gets its own column, but leaf rows show only the deepest label, while parent labels are placed in separate group header rows. compact: all row levels are shown with indentation in a single column (the row header width expands so that even long labels fit within the narrow column).

  • emptyCellDisplaystring

    How cells whose value is null (no matching data) are displayed. The builder's default options are '' (blank), '0', and '-'; in code you can also place any string in config.layout.emptyCellDisplay. The row header columns are unaffected and always remain blank.

It combines naturally with the % display modes of §10A.4 as well. percentOfParentRow uses "the total of the parent group one level up" as the denominator in subtotal cells, so nested subtotals are also shown as a ratio relative to the parent rather than 100% (e.g., Busan-Container subtotal = 30 / 35 ≈ 85.7%).

Implementation note: Because the merge layer of VirtualGrid is drawn only on the body pane, the anchor of a merged cell does not appear on screen in the frozen row header area. pivotResultToGrid does not treat the subtotal/grand total labels as merges and leaves them in their respective columns, leaving the remaining row header columns blank. This matches the appearance of Excel's compact/outline subtotal rows.

Layout options usage example tsx
import { buildPivot, pivotResultToGrid, type PivotConfig } from 'hyper-xl';

const config: PivotConfig = {
  rows: [{ key: 'region' }, { key: 'product' }],
  columns: [{ key: 'month' }],
  values: [{ key: 'qty', agg: 'sum' }],
  filters: [],
  layout: {
    subtotalPosition: 'bottom',
    reportLayout: 'outline',
    emptyCellDisplay: '-',
  },
};

const result   = buildPivot(rows, config);
const snapshot = pivotResultToGrid(result, undefined, config.layout);
// result.rowSubtotals: includes depth/path/leafFrom-leafTo + displayValues
// snapshot.rows:       outline mode → a group header row is automatically inserted for each non-leaf group

Refresh All (§10A.8 P1)

When several PivotBuilder instances on one screen look at the same source array, you may want to recompute them all with a single click. Each builder's "Refresh" button only recomputes itself, so in a multi-pivot environment you wrap the tree with PivotRefreshScope and create a batch trigger with the usePivotRefreshAll() hook.

  • PivotRefreshScope{ children: ReactNode }

    It supplies a monotonically increasing nonce and the refreshAll callback to the child tree via React context. Because every PivotBuilder within the same scope reads this nonce together as a useMemo dependency, a single bump makes them all call buildPivot again. Pivots outside the context respond only to their own button as before.

  • usePivotRefreshAll()() => void

    The trigger called by a child component (usually a "Refresh All" button at the top of the page). When used outside of PivotRefreshScope, it returns a safe no-op, so you can mount the same component as-is on a single-pivot path or a scope-less path.

Implementation note: The scope nonce operates in parallel with the builder's local refresh nonce. If one is bumped, only that side may respond, and if both change at the same time, it recomputes only once. Because it is designed so that even an in-place mutation that does not change the data identity forces a recompute, the screen updates even if you call it after directly modifying the source array.

Tying two PivotBuilders into one scope tsx
import { PivotBuilder, PivotRefreshScope, usePivotRefreshAll } from 'hyper-xl';

function PivotPage({ rows }: { rows: Row[] }) {
  return (
    <PivotRefreshScope>
      <RefreshAllButton />
      <PivotBuilder rows={rows} availableFields={fields} initialConfig={byRegion} />
      <PivotBuilder rows={rows} availableFields={fields} initialConfig={byProduct} />
    </PivotRefreshScope>
  );
}

function RefreshAllButton() {
  const refreshAll = usePivotRefreshAll(); // no-op when outside the scope
  return <button onClick={refreshAll}>Refresh All</button>;
}

Show Details (§10A.9 P1)

Double-clicking a value cell shows the source rows that the cell aggregated in a modal. This is the same behavior as Excel's "Show Details". Whether the cell is in the body, a subtotal, a grand total row, a grand total column, or the top-left grand total, you can trace back to the source rows with the same API.

  • onShowDetails?(details, rows) => void

    Intercepts a value cell double-click and routes it to your own UI (a separate sheet/tab/dialog). When a callback is registered, the default modal does not open, and details is a PivotDrillDownDetails, while rows is a ReadonlyArray<Row> extracted from the source array.

  • disableShowDetails?boolean

    A switch that fully disables the built-in modal. It is meaningless when onShowDetails is present; when you want to ignore the double-click itself, you can specify both or just enable this one.

  • PivotDetailsModalPivotDetailsModalProps

    A component for headless consumers who want to render the built-in modal directly outside the builder. It supports a path where you combine only buildPivot + pivotResultToGrid + pivotDrillDownAt + resolvePivotDrillDown to build your own UI and reuse only the library's dialog.

  • pivotDrillDownAt(snapshot, row, col)PivotDrillDownTarget | null

    Grid coordinates → drill-down target. null if it is a header, label, or corner cell. The returned target distinguishes one of 'body' | 'subtotal' | 'grandTotalRow' | 'grandTotalColumn' | 'grandTotal' via kind.

  • resolvePivotDrillDown(target, result)PivotDrillDownDetails

    Target × PivotResult → source row indices + row/column path + value field descriptor. It is a pure function, and if needed you can get the actual Row objects in one step instead of indices via pivotDrillDownRows(target, result, rows).

The engine exposes the minimal data needed for double-click traceback directly in PivotResult: rowLeafSourceIndices[r] · columnLeafSourceIndices[c] · effectiveSourceIndices. The per-row and per-column leaf source indices are the result after applying each axis's filters (filter area + label filter + value filter), and effectiveSourceIndices is the intersection of the two axis filters: that is, the indices of the rows "visible on both sides". Consumers that do not go through the grid, such as charts/exports, can also trace the source with the same indices.

Accessibility: The modal is exposed with role="dialog" + aria-modal="true", remembers the active element when opened, and restores focus when closed. Esc, background click, and the close button are all three close triggers, and Esc is intercepted only inside the dialog so it does not break the selection-clear shortcut of the grid outside.

Body cell double-click → extract source rows via onShowDetails tsx
import { PivotBuilder, type PivotDrillDownDetails, type Row } from 'hyper-xl';

function ShipmentPivot({ rows }: { rows: Row[] }) {
  const [drill, setDrill] = useState<{ details: PivotDrillDownDetails; rows: ReadonlyArray<Row> } | null>(null);

  return (
    <>
      <PivotBuilder
        rows={rows}
        availableFields={fields}
        initialConfig={config}
        // You receive the (row path, column path, value field) of the double-clicked cell + the
        // array of source rows that the cell aggregated, together. You can route them to a separate sheet, or
        // mount PivotDetailsModal directly.
        onShowDetails={(details, sourceRows) => setDrill({ details, rows: sourceRows })}
      />
      {drill ? (
        <PivotDetailsModal
          details={drill.details}
          sourceRows={drill.rows}
          availableFields={fields}
          onClose={() => setDrill(null)}
        />
      ) : null}
    </>
  );
}

Preset Pivots (§10A.11 P1)

So that the pivot configurations expected to be frequently built in the wiring system can be applied instantly, we provide PivotConfig presets (P1 5 + §10A.11 P2 3 = 8 in total) together with a fake wiring dataset (fake wiring manifest) that can validate those presets. Just inject them as-is into PivotBuilder's initialConfig or buildPivot.

  • WIRING_PIVOT_PRESETSReadonlyArray<WiringPivotPreset>

    A frozen array that exposes the presets in order. The first three items are the "wiring request total" family (by discharge port / by product type / by department), the fourth item is the cumulative shipment volume based on rows=discharge port × columns=month (the §10A.5 month grouping of shipDate), and the fifth item is "Actual vs. Plan ratio (%)" which shows the shipment volume based on department×month with percentOfRow (§10A.4). Later, §10A.11 P2 added 3 more based on voyage / license / production progress, so the array now holds 8 in total.

  • WIRING_PIVOT_PRESET_BY_IDRecord<WiringPivotPresetId, WiringPivotPreset>

    A map form that lets you directly look up and use an individual item in situations where the preset is already designated by button id, URL slug, etc. The items point to the same instances in WIRING_PIVOT_PRESETS.

  • buildWiringShipmentDataset(opts?: { count?: number; seed?: number }) => Row[]

    A fake wiring manifest made with a deterministic Mulberry32 PRNG. By default it distributes shipDate across 60 rows, seed 0xc0ffee, and the range 2026-01-01 ~ 2026-05-31, which is sufficient to validate the §10A.5 date grouping (year/quarter/month/week/day). Because the same seed always returns the same rows, you can safely reuse it in snapshot / Storybook tests.

  • WIRING_PRESET_FIELDSReadonlyArray<PivotAvailableField>

    A field descriptor list aligned 1:1 with the dataset above (11 in total). It is exposed in the order of discharge port, product type, department, month, ship date, quantity, shipment volume (t), plan (t), voyage, license status, and production progress (%). If you pass it as-is to PivotBuilder's availableFields, the presets and the dataset combine untouched.

Because PivotBuilder is an uncontrolled component, initialConfig is applied only once at mount. When the user presses a chip to switch presets, the recommended pattern is to set the React key to the preset id so that the builder is remounted. The demo's PivotPresetShowcase uses this approach.

Sub-path build: Consumers who need only the presets can import them via the hyper-xl/pivot/presets entry point. Because the engine (buildPivot), builder (PivotBuilder), and drill-down (PivotDetailsModal) code are not included, the bundler pulls in only a small chunk of around 6KB that it does not use elsewhere. Because the main hyper-xl entry point re-exports the same symbols as-is, existing code remains compatible without changes.

To define your own preset bank as a consumer, use the generic PivotPreset<Id extends string> exposed by the library. If you pass your own id literal union as the parameter, you can narrow a switch to an exhaustive set of branches, and you can build an array in the same shape as WIRING_PIVOT_PRESETS without casting (e.g., PivotPreset<'order-summary' | 'sla-status'>[]).

"Actual vs. Plan ratio (cumulative ratio %)": A true "running total %" will be expressed via the runningTotal / differenceFrom display to be introduced in §10A.4 P2. The P1 preset uses percentOfRow, one of the five % modes the library currently supports, to approximate "the ratio that each month occupies in the row cumulative of that department". The plan (t) measure is left at the original tonnage so that the denominator can be verified.

Apply a preset instantly + dataset tsx
import { PivotBuilder } from 'hyper-xl';
// Preset-only sub-path: does not pull in the engine/builder/drill-down.
import {
  WIRING_PIVOT_PRESETS,
  WIRING_PRESET_FIELDS,
  buildWiringShipmentDataset,
  type WiringPivotPreset,
} from 'hyper-xl/pivot/presets';
import { useMemo, useState } from 'react';

export function WiringPresetShowcase() {
  // Deterministic fake manifest: the same seed always returns the same rows.
  const rows = useMemo(() => buildWiringShipmentDataset(), []);
  const [presetId, setPresetId] = useState<WiringPivotPreset['id']>(
    WIRING_PIVOT_PRESETS[0]!.id,
  );
  const preset =
    WIRING_PIVOT_PRESETS.find((p) => p.id === presetId) ?? WIRING_PIVOT_PRESETS[0]!;

  return (
    <>
      <div role="group" aria-label="피벗 프리셋">
        {WIRING_PIVOT_PRESETS.map((p) => (
          <button
            key={p.id}
            type="button"
            aria-pressed={p.id === presetId}
            title={p.description}
            onClick={() => setPresetId(p.id)}
          >
            {p.label}
          </button>
        ))}
      </div>
      {/* PivotBuilder is uncontrolled: force a remount via key to re-apply initialConfig */}
      <PivotBuilder
        key={preset.id}
        rows={rows}
        availableFields={WIRING_PRESET_FIELDS}
        initialConfig={preset.config}
      />
    </>
  );
}

Multiple Aggregations (§10A.3 P2)

In the Value area, you can add the same field multiple times and display each with a different aggregation simultaneously. Example: sum of qty + average of qty. Because the engine tracks per-chip settings via index-based dispatch, there is no conflict even if the same field is added twice. In the builder UI, each Value chip has a ⎘ Duplicate button attached, so with a single click you can copy the same chip to the next slot and change it to a different agg or valueDisplay.

Value Display Format: Calculation Modes (§10A.4 P2)

In addition to the 5 % modes of P1, the 7 modes of Excel's "Show Values As" calculation family have been added (the full PivotValueDisplay is 13 in total). Among them, index is the two-axis symmetric formula (cell × grand total) / (row total × column total), so it has no axis concept, while the remaining 6 have the concept of an axis, so PivotValueField.valueDisplayAxis ('column' default / 'row') determines which direction the transformation follows. For modes where the axis is meaningful (the internal set PIVOT_VALUE_DISPLAY_AXIS_AWARE — an implementation detail that is not exported from the package), a calculation direction dropdown is automatically shown next to the display format in the builder UI.

  • differenceFromPreviousDifference

    The first cell of that axis is null, and subsequent cells are current − previous. If a null cell intervenes, the chain breaks at that point and becomes null again.

  • percentDifferenceFromPrevious% Difference

    (current − previous) / previous. If the previous value is 0 or null, the result is null. The builder draws it with a % formatter.

  • runningTotalRunning Total

    A cumulative sum along the axis. A null cell is treated as 0 so the running total keeps progressing (Excel behavior).

  • percentOfRunningTotalRunning Total %

    cumulative up to each cell / total of the entire axis. The last cell is 100%.

  • rankAscending · rankDescendingRank

    RANK.EQ semantics: ties share the same (lower) rank and null cells are excluded from the rank counting.

  • indexIndex

    (cell × grandTotal) / (rowTotal × columnTotal): Excel's index formula. If the row/column partial total is 0, the result is null.

Implementation note: Just like the % modes, the calculation modes keep the original aggregation in matrix and transform only displayMatrix. When neither is active, displayMatrix points directly to matrix, so there is no overhead.

Manual Text Grouping + Collapse (§10A.5 P2 / §10A.9 P2)

A icon has been added to the chips of the row/column area, which opens the manual group popover. A group is defined as { label, values: [...] }, and the values within the same group are merged under a single group label. Matching is based on the engine's internal canonical equality (groupKeyOf, a private implementation), so integer-type representations like '1' and 1 are grouped together as well. Values that do not match are shown as-is with their original labels.

When the row axis has two or more levels, PivotGridSnapshot.collapsibleRowGroups is exposed to tell you the {depth, pathKey, leafFrom, leafTo} of each non-leaf group. Toggling in the sidebar's Collapse/Expand Group panel hides all leaf rows of that group and forces the subtotal row to be shown (forced exposure even if subtotalPosition: 'none'). "Collapse All" / "Expand All" / "Drill Up" (remove the innermost row field) actions are also provided. The collapsed state is passed via the fourth argument adapterOptions.collapsedRowGroupKeys of the root-exported pivotResultToGrid(result, labels?, layout?, adapterOptions?). Note: the option type PivotGridAdapterOptions and the key builder pivotRowGroupKey(path) are not exposed in the public package API (root export) — into collapsedRowGroupKeys (ReadonlySet<string>) just put the PivotGridSnapshot.collapsibleRowGroups[].pathKey values as-is.

Slicers · Timeline (§10A.6 P2)

Visual controls that filter multiple pivots simultaneously have been added. All PivotBuilder instances within a subtree wrapped by PivotSlicerScope subscribe to the same slicer state and are all updated together with a single click. This is essentially Excel's "Report Connections" mental model automated via context.

  • <PivotSlicerScope>Provider

    A context provider that holds the selection state. All PivotBuilder instances within the same scope receive the row predicate published by the slicer as an additional filter when calling buildPivot. Because it is orthogonal to the existing PivotRefreshScope, you can also wrap with both.

  • <PivotSlicer field="..." rows={...} />React.FC

    A category chip panel. It lists the unique values of field as toggle chips and passes through only the subset the user selected. The default is "select all" (no filter is applied). "Deselect All" publishes an empty set to deliberately empty the pivot, and "Clear Filter" removes the slicer from the scope to revert to passing everything through. When id is unspecified, field is used as the identifier.

  • <PivotTimeline field="..." rows={...} />React.FC

    A date slicer (year/quarter/month zoom). It buckets the range between the min/max of the parseable dates in the source rows by the selected unit and shows it. A single click is one bucket, and Shift-click is a range selection from the anchor that publishes a half-open interval [startMs, endMs). Clicking the same single bucket again clears the range. The default value of initialUnit is 'month'.

  • usePivotSlicerRowPredicate()Hook

    It returns a row predicate that AND-combines all active slicers within the scope. If outside the scope or there are no active slicers, it returns null so that the dependent useMemo does not churn meaninglessly. PivotBuilder internally calls this hook and passes it as the option field of buildPivot(rows, config, { extraRowFilter }).

  • buildPivot: options.extraRowFilter(row, index) => boolean

    An option added to the engine. It is AND-combined with config.filters, and the effectiveSourceIndices of drill-down keeps the source index basis as-is (because rows are not pruned in advance, the "mapping to the source row" is not broken).

If the field a slicer holds is not in a row's data, that row passes through. This is a safeguard so that even if you mix pivots with different data shapes in one scope, the slicer does not unintentionally empty unrelated pivots. If you need strict matching, separate the scopes.

Layout Policy: Repeat Header · Stripes · Style Presets (§10A.7 P2)

  • layout.repeatItemLabelsboolean

    In the outline (outline) / compact (compact) format, it fills the parent label cells that were empty into all leaf rows, so that the row header does not break even when the sorted / filtered result is exported to print or CSV.

  • layout.bandedRows · layout.bandedColumnsboolean

    It applies an alternating background color to even / odd body cells to improve table readability. Because it is passed to the grid via cellFormats, it integrates with the grid's default rendering flow.

  • layout.stylePreset'none' | 'light' | 'medium' | 'dark'

    A built-in palette that mimics Excel's "Light / Medium / Dark" family. It applies a different color to each of the header / subtotal / grand total / stripes. If none, cellFormats stays null and the grid's default look is used. The cellFormats that the consumer passes down takes precedence at the same key.

Auto Refresh (§10A.8 P2)

Two new props have been added to PivotBuilder:

  • autoRefreshIntervalMsnumber | null

    Periodic recomputation based on setInterval. This is a per-pivot timer that recomputes that builder only — even when inside a PivotRefreshScope, it bumps only its own local nonce and does not fan out across the whole scope (to avoid the duplication of N separate timers on the same cadence being installed on N pivots). To refresh every pivot in the scope on a single cadence, call the usePivotAutoRefresh(intervalMs, opts) hook once at the scope level (e.g. in a shared toolbar) — this hook bumps the scope nonce so all pivots recompute together. When 0 / null / omitted, the timer is disabled.

  • refreshOnMountboolean

    Just like Excel's "Refresh data when opening the file", it fires a refresh once at the mount point. The default is false.

External · Dynamic Data Sources (§10A.1 P2)

The rows prop of PivotBuilder still accepts a ReadonlyArray<Row> as-is, but when rows flow in from a mutable source such as a different grid, a DB query, or an external feed, if you connect them with a hook paired with a RowSource adapter, they are recomputed automatically without a separate ‘Refresh’ click. Four factories and three hooks are exposed.

  • staticRowSource(rows)(rows: ReadonlyArray<Row>) => RowSource

    A no-change adapter that merely wraps an existing array into the RowSource shape. subscribe is a no-op. Use it when you want to put static/dynamic sources together inside one combinedRowSource.

  • dynamicRowSource(initial?)(initial?: ReadonlyArray<Row>) => DynamicRowSource

    A mutable in-memory source. add(row | rows[]), remove(predicate), setRow(predicate, updater), replace(rows), and clear() are exposed, and whenever a mutation occurs, it publishes a new array reference and then notifies subscribers. When zero rows are added or the predicate does not match, it does not send a notification, so no unnecessary recomputation occurs.

  • asyncRowSource(fetcher, options?)(fetcher: () => Promise<Rows> | Rows, options?) => AsyncRowSource

    An asynchronous source such as a DB query/HTTP fetch. Auto fetch right after mount (or manual: true), refetch() manual trigger, setIntervalMs(ms) dynamic polling cadence, stale response blocking via a race token, and an onError error sink. When dispose() is called, the timer and all subscribers are cleaned up.

  • combinedRowSource(...sources)(...sources: ReadonlyArray<RowSource>) => CombinedRowSource

    A virtual source that concatenates the rows of multiple RowSource instances in input order. If one of the inputs changes, the combined result is republished as well. This is the standard solution for the “pivot based on different grids” scenario: even if grid A and grid B each hold their own dynamicRowSource, a single pivot aggregates the union of the two.

  • useRowSource(source | rows)(source: RowSource | ReadonlyArray<Row>) => ReadonlyArray<Row>

    A React hook. Implemented on top of useSyncExternalStore, it returns the latest snapshot without tearing even in concurrent rendering. If you pass a raw array as-is, it returns that array, so you can easily build a component that accepts either "an array or a source". Just pass the return value as-is to <PivotBuilder rows={…}> and you are done.

  • useDynamicRowSource(initial?)(initial?: ReadonlyArray<Row>) => DynamicRowSource

    A convenience hook that creates a dynamicRowSource with seed rows only once at mount and keeps it in the useMemo cache. initial is read only on the first render, and subsequent arrays are replaced via source.replace(rows).

  • useAsyncRowSource(fetcher, options?)(fetcher, options?) => AsyncRowSource

    It creates an asyncRowSource only once at mount and, when the host component unmounts, automatically calls dispose() to clean up the polling timer and the subscriber pool. fetcher is tracked by ref, so it is safe even if you pass an inline closure on every render. The polling cadence can be changed at runtime via source.setIntervalMs(ms).

The existing <PivotBuilder rows={array}> API does not change. RowSource is an opt-in additional surface, and demos/consumers that have not adopted the adapter are unaffected. In the demo's “External · Dynamic Data Sources” section, the +Add Row / Remove Last Row / Clear All / Restore Initial Value buttons show the same pattern live.

import { useMemo } from 'react';
import { PivotBuilder, dynamicRowSource, useRowSource, type Row } from 'hyper-xl';

function ExternalPivot({ seed }: { seed: Row[] }) {
  const source = useMemo(() => dynamicRowSource(seed), []);
  const rows = useRowSource(source);
  return (
    <>
      <button onClick={() => source.add(makeRow())}>+ 행 추가</button>
      <PivotBuilder rows={rows} availableFields={FIELDS} />
    </>
  );
}

Preset Additions (§10A.11 P2)

In addition to the existing 5 P1 presets, 3 P2 presets have been added. Loading Status by Voyage (rows: voyage, columns: discharge port, values: shipment volume + quantity), License Status by Department (rows: department × license, columns: month-grouped shipDate, values: count), Production Progress by Department (rows: department, columns: month, values: average progress rate + sum of quantity). To the dataset, the voyage / license / productionPct fields have been added so that the progress rate is simulated to be correlated by license status.

Pivot Chart (§10A.10 P2)

PivotChart renders pivot results as a bar / line / pie chart. It draws with pure SVG without any external chart library, and subscribes directly to the context of PivotSlicerScope · PivotRefreshScope, so that it updates simultaneously with the PivotBuilders in the same scope on the same slicer / "refresh all" signals. Each chart visualizes a single value field, and for the category axis you can choose either 'row' (default) or 'column'.

  • rowsReadonlyArray<Row>

    Source rows. It takes the same shape as PivotBuilder, and internally calls buildPivot(rows, config).

  • configPivotConfig

    The pivot spec. Because the chart reads the engine's grand-total slice to visualize, even if the consumer turns off the showGrandTotal* toggle the chart internally uses a copy that is forcibly turned on (the toggle of a sibling PivotBuilder is preserved as is). Reference stability required: do not pass an object literal inline inside JSX; store it in useState / useMemo / a module constant.

  • kind'bar' | 'line' | 'pie'

    The chart kind. One of bar / line / pie. Since a single component supports all three, mounting three charts at once with the same config produces an intuitive comparison view.

  • valueFieldIndexnumber (default 0)

    An index into config.values. If out of range, it is clamped to 0.

  • categoryAxis'row' | 'column' (default 'row')

    The axis used to build categories. 'row' produces one point per row leaf, 'column' produces one point per column leaf. If the selected axis has no field, an empty state is displayed.

  • width · heightnumber (default 520 · 280)

    The SVG viewBox size. Together with CSS width: 100%, it scales while preserving aspect ratio (xMidYMid meet).

  • title · labels.emptystring

    Optional. Use this to customize the header text and the empty-state message in Korean. When title is not specified, the label of the value field becomes the default title.

  • pivotResultToChartSeries(result, options?)function

    A headless helper used when you want to use your own chart library (D3, recharts, etc.). It extracts a { label, value }[] list from PivotResult. To obtain accurate leaf values even for non-distributive aggregations (average / min / max / variance / stdDev / product), it reads the engine's grand-total slice and does not sum cells.

Bar / line charts display null values as gaps (the line breaks, and the bar is not drawn). Pie charts automatically omit null and negative / 0 slices. Excel also does not assign meaning to a "negative slice". Using a pie chart on data that contains negative values makes it look truncated, so in such cases a bar chart is recommended.

Pivot + slicer + chart: bind them all in a single scope tsx
import {
  PivotBuilder,
  PivotChart,
  PivotRefreshScope,
  PivotSlicer,
  PivotSlicerScope,
  type PivotConfig,
  type Row,
} from 'hyper-xl';

const chartConfig: PivotConfig = {
  rows:    [{ key: 'region' }],
  columns: [],
  values:  [{ key: 'qty', agg: 'sum', label: 'Quantity total' }],
  filters: [],
};

export function Dashboard({ rows }: { rows: Row[] }) {
  return (
    <PivotRefreshScope>
      <PivotSlicerScope>
        <PivotSlicer field="region" title="Discharge port" rows={rows} />
        {/* The three charts are within the same scope, so they share the slicer and refresh */}
        <PivotChart rows={rows} config={chartConfig} kind="bar"  title="Bar" />
        <PivotChart rows={rows} config={chartConfig} kind="line" title="Line" />
        <PivotChart rows={rows} config={chartConfig} kind="pie"  title="Pie" />
        <PivotBuilder rows={rows} availableFields={fields} initialConfig={chartConfig} />
      </PivotSlicerScope>
    </PivotRefreshScope>
  );
}
Headless: map it yourself and draw with D3/recharts tsx
import { buildPivot, pivotResultToChartSeries } from 'hyper-xl';

const result = buildPivot(rows, config);
const series = pivotResultToChartSeries(result, {
  valueFieldIndex: 0,
  categoryAxis:    'row',
});
// series → [{ label: 'Busan', value: 30 }, { label: 'Incheon', value: 70 }, ...]
// You can pass this array directly to recharts/visx/D3 to draw a chart with your own styling.

Drag & drop limitations (MVP)

  • Only desktop HTML5 DnD is supported. The mobile touch / keyboard reordering UI is in the next PR.
  • It does not draw a drop indicator (insertion indicator). A chip always goes to the end of the area; to move it to the middle, first move it to another area and then drop it again.
  • There is no ARIA live-region announcement for area changes yet.

Sheets / tabs (Workbook)

FeatureHigh PRD §14: a workbook model with multiple sheets. Adding / deleting / renaming sheets, tab colors, reordering, duplicating, hiding/showing, and protection (read-only) metadata are all managed in one place. Since <XlReact> always draws only the rows / formats of a single sheet, the per-sheet payload (rows / cellFormats / merges) is kept directly by the consumer as a Record<SheetId, …>. The library handles only the metadata of "which sheets exist and which sheet is currently active".

Components

  • createWorkbook(options?): creates a Workbook object that has an initial sheet list and an activeSheetId. If all seed sheets are hidden: true, the first sheet is automatically promoted to the visible state.
  • workbookReducer(workbook, action): a pure reducer. It handles the nine actions 'add' | 'delete' | 'rename' | 'recolor' | 'reorder' | 'duplicate' | 'setHidden' | 'setProtected' | 'activate', and rejects integrity violations (duplicate name · duplicate id · deleting the last sheet · hiding the last visible sheet) by returning the same reference.
  • useWorkbook(options?): wraps workbookReducer in useReducer and provides stable callbacks (addSheet, deleteSheet, renameSheet, …) and derived values (activeSheet, visibleSheets, hiddenSheets) all at once.
  • <SheetTabBar />: a tab strip placed at the bottom of the grid. It includes "+", a context menu (rename · duplicate · color · hide · protect · delete), a hidden-sheets dropdown, inline name editing (Enter to confirm · Esc to cancel), and HTML5 drag reordering. Visual properties are all exposed through --xl-react-sheet-tab-* tokens, and the UI text can be replaced via SheetTabBarLabels. It includes two presets in the bundle: DEFAULT_SHEET_TAB_BAR_LABELS (English) and KOREAN_SHEET_TAB_BAR_LABELS (Korean).
  • workbookToMultiSheetEntries(workbook, getSnapshot, options?): converts a workbook into the MultiSheetEntry[] that exportMultiSheetXlsx accepts. When the consumer passes a resolver that returns a per-sheet { rows, columns, cellFormats?, merges? } snapshot, it exports to .xlsx all at once while skipping hidden sheets by default (can be forced with includeHidden: true).

Only sheet metadata is owned by the library

A Sheet has only the five fields { id, name, color?, hidden?, protected? }. Row data, cell formats, undo stacks, column definitions, and so on are all kept by the consumer in their own Map keyed by sheet id, and whenever activeSheetId changes, you just stream the corresponding payload to <XlReact> as rows · cellFormats. Since the library does not hold the payload, you can freely combine it with external data sources (server pagination · dynamic queries · CRDT) as well. protected is also merely a metadata flag, and the actual write blocking is applied by the consumer passing it to the grid like readOnly={activeSheet.protected}.

Invariants maintained

  • At least one sheet. 'delete' does not remove the last remaining sheet in the workbook.
  • At least one visible sheet. Even if hidden sheets remain, an action that deletes ('delete') or hides ('setHidden') the last visible sheet is rejected. This is because falling into this state would leave the user unable to click any sheet.
  • activeSheetId is always a visible sheet. If the active sheet is hidden or deleted, it automatically moves to the next visible sheet on the spot. 'activate' does not apply to hidden sheets.
  • Duplicate name / duplicate id rejection. A blank or duplicate rename is a no-op, and by returning the same reference the consumer can detect whether it was ignored.
  • A duplicate is created in a visible and editable state. Even if the original is hidden or protected, the duplicate is always created in a visible and editable state. This is because "an editable copy of a protected sheet" is the default intent of duplication.
Minimal wiring: keep per-sheet rows separated in an external Map tsx
import { useEffect, useRef, useState } from 'react';
import {
  XlReact,
  SheetTabBar,
  useWorkbook,
  KOREAN_SHEET_TAB_BAR_LABELS,
  type Row,
  type SheetId,
} from 'hyper-xl';

function Workbook() {
  const workbook = useWorkbook({
    initialSheets: [{ id: 'sheet-1', name: 'Sheet1' }],
  });

  // The per-sheet payload is outside the library. The key is sheet.id as is.
  const [data, setData] = useState<Record<SheetId, { rows: Row[] }>>(() => ({
    'sheet-1': { rows: [{ id: 'r1', data: {} }] },
  }));

  // Sync the payload Map with sheet additions/deletions.
  const prevRef = useRef(workbook.workbook);
  useEffect(() => {
    const prev = prevRef.current;
    const next = workbook.workbook;
    const added = next.sheets.filter((s) => !prev.sheets.some((p) => p.id === s.id));
    const removed = prev.sheets.filter((p) => !next.sheets.some((s) => s.id === p.id));
    if (added.length || removed.length) {
      setData((m) => {
        const out = { ...m };
        for (const s of added) out[s.id] = { rows: [] };  // Add as an empty sheet
        for (const s of removed) delete out[s.id];
        return out;
      });
    }
    prevRef.current = next;
  }, [workbook.workbook]);

  const active = data[workbook.activeSheet.id] ?? { rows: [] };

  return (
    <>
      <XlReact
        columns={columns}
        rows={active.rows}
        onCellChange={(change) =>
          setData((m) => {
            const sheetId = workbook.activeSheet.id;
            const rows = m[sheetId].rows.map((row, i) =>
              i === change.coord.row
                ? { ...row, data: { ...row.data, [change.columnId]: change.nextValue } }
                : row,
            );
            return { ...m, [sheetId]: { rows } };
          })
        }
        readOnly={!!workbook.activeSheet.protected}
      />
      <SheetTabBar controller={workbook} labels={KOREAN_SHEET_TAB_BAR_LABELS} />
    </>
  );
}
Export multiple sheets to .xlsx at once tsx
import {
  workbookToMultiSheetEntries,
  exportMultiSheetXlsx,
  triggerBlobDownload,
} from 'hyper-xl';

async function exportWorkbook() {
  // The getSnapshot callback receives a Sheet object, not a sheetId.
  const entries = workbookToMultiSheetEntries(
    workbook.workbook,
    (sheet) => ({
      rows: data[sheet.id].rows,
      columns,
      cellFormats: formats[sheet.id],
      merges: merges[sheet.id],
    }),
    // The default for includeHidden is false: hidden sheets are automatically excluded.
    { defaults: { dateFormat: 'yyyy-mm-dd' } },
  );
  // exportMultiSheetXlsx returns a Blob — save the file via triggerBlobDownload.
  const blob = await exportMultiSheetXlsx(entries);
  triggerBlobDownload(blob, 'workbook.xlsx');
}

Coordinate consistency with drag reordering / hidden sheets

The drop handler of <SheetTabBar /> reorders based on the visible sheet order that the user sees, then converts the result into an absolute index of the entire sheets array and dispatches 'reorder'. So even if hidden sheets are interleaved among the visible sheets, the visual drag result and the internal order do not diverge. When dispatching 'reorder' directly, note that toIndex is an index of the entire array.

API surface

  • Workbook{ sheets: ReadonlyArray<Sheet>; activeSheetId: SheetId }

    A purely serializable shape: there are no class instances or closures, so it can be saved / restored as is.

  • Sheet{ id: SheetId; name: string; color?: string | null; hidden?: boolean; protected?: boolean }

    Keeping hidden / protected as keys only when turned on is recommended: false is not stored, to keep external equality comparison clean. color can express "no color" with null.

  • WorkbookActiondiscriminated union

    9 variants of { type: 'add' | 'delete' | … }. On an integrity violation the reducer returns the same reference.

  • SheetTabBarControllersubset of WorkbookController

    A narrow interface that picks out only the callbacks the tab bar actually needs. A consumer using their own dispatcher can plug in the component as is as long as it satisfies just this shape.

API: XlReactProps

A full prop table organized by feature. For detailed context, refer to each feature section.

Data

  • columnsAnyColumn[]
  • rowsRow[]
  • rowHeightnumber
  • columnWidthnumber
  • overscannumber
  • classNamestring
  • readOnlyboolean
  • minColumnWidth / minRowHeightnumber

Selection & editing

  • onSelectionChange(snapshot: SelectionSnapshot) => void
  • onEditRequest(coord: CellCoord) => void
  • onCellChange(change: CellChange) => void
  • onCellsClear(payload: CellsClearPayload) => void

Freeze panes

  • freezeFirstRow / freezeFirstColboolean
  • freezeRowCount / freezeColCountnumber

Row / column manipulation

  • onRowsInsert / onRowsDelete
  • onColumnsInsert / onColumnsDelete
  • onRowsReorder / onColumnsReorder

Clipboard

  • onCopy / onCut
  • onPasteRequest / onPasteSpecialRequest

Sort & filter

  • sortState / onSortStateChange
  • filterState / onFilterStateChange
  • filterPanelRows
  • onSortAscending / onSortDescending / onSortCustomRequest
  • onFilterByValueRequest / onClearFilterRequest

Context menu intent

  • onCellFormatRequest
  • onInsertNoteRequest / onInsertHyperlinkRequest

Undo

  • enableUndo / undoMaxEntries / undoMaxBytes

Zoom

  • zoom / defaultZoom / onZoomChange
  • showZoomControl / zoomMin / zoomMax

View mode

  • showGridlinesboolean
  • showHeadersboolean

Format

  • cellFormatsCellFormatResolver | CellFormatsMap
  • onCellFormatsChange(next: CellFormatsMap) => void
  • borderDrawToolBorderDrawTool | null
  • borderDrawSideCellBorderSide
  • mergesReadonlyArray<SelectionRange>

Custom renderers

  • cellRenderersCellRenderers (map | resolver)

Annotations

  • cellAnnotations
  • annotationShowDelayMs / annotationHideDelayMs

Aggregation

  • showSelectionStats / selectionStatsLocale

Protection

  • cellProtection / onProtectedAction

Find & replace

  • enableFindReplaceboolean (default true)

Data validation

  • validationListsRecord<string, ValidationList>

Row hierarchy

  • rowOutlineReadonlyArray<RowOutlineCell | null | undefined>
  • onRowOutlineToggle(rowIndex: number, next: 'collapse' | 'expand') => void
  • rowOutlineIndentPxnumber (default 16)

API: type definitions

Re-exported from 'hyper-xl'. Most are defined in src/types/, but some (the selection · editing · contextMenu · sortFilter · protection families) live in their respective src/XlReact/… modules and are re-exported again by src/types/index.ts.

Core types typescript
type Column<T = unknown>;
type AnyColumn = Column<any>;
type ColumnValidationResult = boolean | string;

interface Row {
  id: string | number;
  data: Record<string, unknown>;
  height?: number;
  level?: number;                          // §6.4 row hierarchy depth
  parentId?: string | number | null;       // §6.4 explicit parent reference
}

interface CellRendererProps<T> {
  value: T;
  row: Row;
  column: Column<T>;
  rowIndex: number;
  columnIndex: number;
  isEditing: boolean;
}

type CellEditCommitNav =
  | 'enter' | 'shift-enter' | 'tab' | 'shift-tab' | 'none';

interface CellEditorProps<T> extends CellRendererProps<T> {
  onCommit: (next: T, nav?: CellEditCommitNav) => void;
  onCancel: () => void;
  mode?: 'edit' | 'overwrite' | 'clear';
  initialDraft?: string;
}

type CellRenderer<T> = (props: CellRendererProps<T>) => ReactNode;
type CellEditor<T> = (props: CellEditorProps<T>) => ReactNode;

// cellRenderers prop: map or resolver
type CellRenderersMap = Record<string, CellRenderer>;
type CellRendererResolver =
  (rowIndex: number, columnIndex: number) => CellRenderer | undefined;
type CellRenderers = CellRenderersMap | CellRendererResolver;
Selection & editing types typescript
interface CellCoord { row: number; col: number; }
interface SelectionRange { start: CellCoord; end: CellCoord; }
interface SelectionSnapshot {
  active: CellCoord;
  ranges: ReadonlyArray<SelectionRange>;
}

interface CellChange {
  coord: CellCoord;
  columnId: string;
  prevValue: unknown;
  nextValue: unknown;
}

interface CellsClearPayload {
  ranges: ReadonlyArray<SelectionRange>;
}
Data validation types typescript
interface ColumnValidation {
  listKey: string;
  strict?: boolean;
}

interface ValidationListOption { value: string; label?: string; }
type ValidationListItem = string | ValidationListOption;
type ValidationList = ReadonlyArray<ValidationListItem>;
type ValidationLists = Readonly<Record<string, ValidationList>>;

// Normalized options: value/label always present (helper return form)
interface ResolvedValidationOption { value: string; label: string; }
Context menu payload typescript · ~30 lines
interface RowsInsertPayload { atIndex: number; position: 'above' | 'below'; count: number; }
interface RowsDeletePayload { rowIds: ReadonlyArray<Row['id']>; rowIndices: ReadonlyArray<number>; }
interface ColumnsInsertPayload { atIndex: number; position: 'left' | 'right'; count: number; }
interface ColumnsDeletePayload { columnIds: ReadonlyArray<string>; columnIndices: ReadonlyArray<number>; }
interface RowsReorderPayload {
  rowIds: ReadonlyArray<Row['id']>;
  rowIndices: ReadonlyArray<number>;
  targetIndex: number;
}
interface ColumnsReorderPayload {
  columnIds: ReadonlyArray<string>;
  columnIndices: ReadonlyArray<number>;
  targetIndex: number;
}

interface ClipboardCopyPayload {
  ranges: ReadonlyArray<{ start: CellCoord; end: CellCoord }>;
  text: string;
}
interface PasteRequestPayload { coord: CellCoord; ranges: ReadonlyArray<SelectionRange>; }
interface PasteSpecialRequestPayload { coord: CellCoord; }

interface SortColumnPayload { columnId: string; columnIndex: number; }
interface FilterByValuePayload { coord: CellCoord; value: unknown; }
interface CellFormatRequestPayload { coord: CellCoord; }
interface CellAddressActionPayload { coord: CellCoord; }
Sort & filter types typescript
type SortDirection = 'asc' | 'desc';
interface SortColumnEntry { columnId: string; direction: SortDirection; }
type SortState = ReadonlyArray<SortColumnEntry>;

interface ColumnValueFilter { selectedValues: ReadonlySet<string>; }
type FilterState = Readonly<Record<string, ColumnValueFilter>>;

const BLANK_FILTER_KEY: string;
function valueToFilterKey(value: unknown): string;
Cell format types typescript
type CellHorizontalAlign = 'left' | 'center' | 'right' | 'justify' | 'distributed';
type CellVerticalAlign = 'top' | 'middle' | 'bottom' | 'distributed';
type CellBorderLineStyle = 'solid' | 'dashed' | 'dotted' | 'double' | 'thick';

interface CellFont {
  family?: string;
  size?: number;
  bold?: boolean;
  italic?: boolean;
  underline?: boolean | 'single' | 'double';
  strikethrough?: boolean;
  color?: string;
}
interface CellAlign {
  horizontal?: CellHorizontalAlign;
  vertical?: CellVerticalAlign;
  wrap?: boolean;
  indent?: number;
}
interface CellFill { backgroundColor?: string; }
interface CellBorderSide { style?: CellBorderLineStyle; color?: string; width?: number; }
interface CellBorder {
  top?: CellBorderSide;
  right?: CellBorderSide;
  bottom?: CellBorderSide;
  left?: CellBorderSide;
  diagonalDown?: CellBorderSide;  // ╲ in-cell diagonal (SVG overlay)
  diagonalUp?: CellBorderSide;    // ╱ in-cell diagonal
}
interface CellFormat {
  font?: CellFont;
  align?: CellAlign;
  fill?: CellFill;
  border?: CellBorder;
  numberFormat?: string;
}

type CellFormatsMap = Readonly<Partial<Record<string, CellFormat>>>;
type CellFormatResolver = (rowIndex: number, columnIndex: number)
  => CellFormat | null | undefined;
type CellFormats = CellFormatResolver | CellFormatsMap;

type NullablePatch<T> = { [K in keyof T]?: T[K] | null };
type CellFontPatch = NullablePatch<CellFont>;
type CellAlignPatch = NullablePatch<CellAlign>;
type CellFillPatch = NullablePatch<CellFill>;
type CellBorderSidePatch = NullablePatch<CellBorderSide>;
interface CellBorderPatch {
  top?: CellBorderSidePatch | null;
  right?: CellBorderSidePatch | null;
  bottom?: CellBorderSidePatch | null;
  left?: CellBorderSidePatch | null;
  diagonalDown?: CellBorderSidePatch | null;
  diagonalUp?: CellBorderSidePatch | null;
}
type CellFormatPatch = {
  font?: CellFontPatch | null;
  align?: CellAlignPatch | null;
  fill?: CellFillPatch | null;
  border?: CellBorderPatch | null;
  numberFormat?: string | null;
};
type CellBorderPlacement = 'outline' | 'all' | 'top' | 'right' | 'bottom' | 'left' | 'none'
  | 'diagonal-down' | 'diagonal-up';
type CellFormatPatchResolver = (
  current: CellFormat | undefined,
  coord: CellCoord,
) => CellFormatPatch | null | undefined;

interface CellFormatToolbarProps {
  selection: SelectionSnapshot | null;
  cellFormats?: CellFormatsMap;
  onCellFormatsChange: (next: CellFormatsMap) => void;
  disabled?: boolean;
  className?: string;
  // Font / number-format option overrides
  fontFamilies?: readonly CellFormatToolbarFontOption[];
  fontSizes?: readonly number[];
  numberFormats?: readonly CellFormatToolbarNumberFormatOption[];
  // Merge integration (absorbs the CellMergeToolbar feature)
  merges?: ReadonlyArray<SelectionRange>;
  onMergesChange?: (next: SelectionRange[]) => void;
  onMergeClearCovered?: (ranges: SelectionRange[]) => void;
  mergeLabels?: CellMergeToolbarLabels;
  // Border draw tool
  activeBorderTool?: BorderDrawTool | null;
  onBorderDrawToolChange?: (tool: BorderDrawTool | null, side: CellBorderSide) => void;
  // Conditional formatting integration
  conditionalRules?: readonly ConditionalRule[];
  onConditionalRulesChange?: (next: ConditionalRule[]) => void;
  columns?: readonly AnyColumn[];
  conditionalFormatLabels?: Partial<ConditionalFormatToolbarLabels>;
  // Cell style preset integration
  cellStyleRegistry?: CellStyleRegistry;
  cellStyleApplyMode?: CellStyleApplyMode;
  cellStyleLabels?: Partial<CellStyleToolbarLabels>;
  // Format Painter
  formatPainterArmed?: boolean;
  onFormatPainterToggle?: () => void;
  formatPainterLabel?: string;
}

function cellFormatKey(rowIndex: number, columnIndex: number): string;
function resolveCellFormat(
  formats: CellFormats | undefined,
  rowIndex: number,
  columnIndex: number,
): CellFormat | undefined;
function applyCellFormatPatch(
  formats: CellFormatsMap | undefined,
  ranges: ReadonlyArray<SelectionRange>,
  patch: CellFormatPatch | CellFormatPatchResolver,
): CellFormatsMap;
function applyCellBorderPatch(
  formats: CellFormatsMap | undefined,
  ranges: ReadonlyArray<SelectionRange>,
  placement: CellBorderPlacement,
  side: CellBorderSidePatch,
): CellFormatsMap;
Annotation types typescript
type CellAnnotationsMap = Readonly<Record<string, string>>;
type CellAnnotationResolver = (rowIndex: number, columnIndex: number)
  => string | null | undefined;
type CellAnnotations = CellAnnotationResolver | CellAnnotationsMap;

function cellAnnotationKey(rowIndex: number, columnIndex: number): string;
function resolveCellAnnotation(
  annotations: CellAnnotations | undefined,
  rowIndex: number,
  columnIndex: number,
): string | undefined;
Protection types typescript
type CellProtectionPredicate = (rowIndex: number, columnIndex: number) => boolean;
type ProtectedAction =
  | 'edit' | 'clear' | 'paste' | 'fill' | 'cut' | 'move'
  | 'replace' | 'rowDelete' | 'columnDelete';
interface ProtectedActionInfo {
  action: ProtectedAction;
  coords: ReadonlyArray<CellCoord>;
}

API: utilities

The main utilities (representative items) exported from 'hyper-xl'. Beyond this list, the root also exports many helpers and components related to formatting, conditional formatting, cell styles, merging, search, pivot, sheets, printing, and formulas — for the full list, refer to each feature section and src/index.ts.

  • computeAggregates(ranges, rows, columns) => SelectionAggregates

    The same SUM / AVG / COUNT / MIN / MAX computation as the status bar.

  • processInChunks(items, perItem, options?) => Promise<void>

    An async chunked iteration helper used by paste / fill. The callback perItem(item, index) is the second argument, and the options are { chunkSize?, signal? }.

  • BoundedUndoStackclass (options: BoundedUndoStackOptions)

    A standalone undo store with an entry-count + byte limit.

  • defaultColumnLabel(index: number) => string

    A1-style column labels: 0 → 'A', 26 → 'AA'.

  • defaultRowLabel(index: number) => string

    1-based row labels: 0 → '1'.

  • cellAnnotationKey(row, col) => string
  • resolveCellAnnotation(annotations, row, col) => string | undefined
  • cellFormatKey(row, col) => string
  • resolveCellFormat(formats, row, col) => CellFormat | undefined
  • applyCellFormatPatch(formats, ranges, patch) => CellFormatsMap
  • applyCellBorderPatch(formats, ranges, placement, side) => CellFormatsMap
  • valueToFilterKey(value: unknown) => string
  • findMatches(args: FindMatchesArgs) => FindMatch[]

    The row-first match engine used by the find / replace dialog.

  • replaceInValue(value, query, replacement, options) => string | null

    The replacement result of a single cell value (null when no match).

  • compileMatcher(query, options) => CompiledMatcher
  • cellValueToString(value: unknown) => string
  • resolveColumnList(column, validationLists) => ResolvedValidationOption[] | null

    Normalizes the column's validation list. null if not a list cell.

  • filterOptions(options, query) => ResolvedValidationOption[]

    Same partial label · value match as the search box (case-insensitive).

  • isValueInList(value, options) => boolean

    Same rule as strict validation. An empty value is always true.

  • normalizeList(list) => ResolvedValidationOption[]
  • normalizeOption(item) => ResolvedValidationOption
  • FormulaSheetclass

    Sheet helper that manages raw + display values + the dependency graph. See the formula engine.

  • parseFormula(input) => FormulaAst | FormulaParseError

    Parser for arithmetic operations + cell reference syntax.

  • evaluateAst(ast, resolveRef) => number | FormulaErrorCode

    Evaluates the parsed AST with a cell value resolver. Includes error propagation.

  • extractRefs(ast) => { row, col }[]
  • a1ToCoord / coordToA1A1 ↔ {row,col} conversion ($ marker allowed)
  • parseA1(ref) => ParsedCellRef | null

    Decomposes and returns absolute/relative information including the $ marker.

  • shiftFormulaRefs(formula, deltaRow, deltaCol) => string

    Shifts relative references in a formula while keeping $ absolute references fixed: used internally by the auto-fill engine.

  • isFormulaError(value) => boolean

    Checks whether it is one of the FORMULA_ERROR_CODES.

  • SplitPaneViewcomponent @experimental

    Split panel wrapper: 1 / 2 / 4 panels + synchronized scrolling between paired panels.

  • useFullscreen(ref) => { isFullscreen, isSupported, request, exit, toggle }

    Fullscreen API + vendor-prefix fallback. Automatically releases the fullscreen lock when the host unmounts.

  • useWorkbookBroadcast<T>(channelName) => { latest, broadcast, isSupported }

    Same-origin BroadcastChannel wrapper: used for new window synchronization.

  • openSheetInNewWindow(options?) => Window | null

    window.open wrapper. The default target is a reusable named window.

Feature changelog

Features merged up to the current revision (newest first).

Feature Type Priority Completion date
Print (Print: preview modal · A4/A3/A5/Letter/Legal/Tabloid paper + landscape/portrait · margins / scale (10~400%) · header·footer 3-zone + &P/&N/&D/&T/&F/&A placeholders · print area · row/column repeat · page break preview overlay · @media print page breaks · pure paginate() engine (reusable for PDF))FeatureMedium2026-05-29
View modes (View Modes: gridlines / header show·hide · 1 / 2 / 4 split panels + scroll synchronization · new window BroadcastChannel synchronization · Fullscreen API)FeatureMedium2026-05-28
Sheets / tabs (Workbook: multi-sheet model · add / delete / rename / tab color / drag reorder / duplicate · hide · protection (read-only) · per-sheet payload owned by the consumer · multi-sheet .xlsx export bridge · English / Korean label presets)FeatureHigh2026-05-28
Pivot table (Pivot Table: 4-area drag & drop builder · 9 aggregations · 13 value display formats (P1 % 6 + P2 calculation modes 7) · row/column grouping (date units · numeric ranges) · sort · label filter · axis value filter (Top N / above·below average / threshold) · subtotal position · report format (compact/outline/tabular) · empty cell display value · grand total · bulk refresh of multiple pivots · details (Show Details: extract source rows by double-clicking a value cell) · preset pivots (wiring request total / monthly cumulative / actual vs. plan %) · pivot charts (bar / line / pie) · external · dynamic data source (RowSource adapter: another grid / DB query / automatic recalculation when rows are added) · renders results to an XlReact grid)FeatureHigh2026-05-28
Row hierarchy (Row Hierarchy / Grouping: multi-level tree + collapse/expand)FeatureMedium2026-05-27
Import / export (Excel · CSV · TSV: formats · formulas · multiple sheets · column mapping · validation reporting)FeatureHigh2026-05-27
Formula engine (Formula Engine: arithmetic operations + cell references + $ absolute references + relative shifting on auto-fill)FeatureMedium2026-05-27
Custom cell renderer (Custom Cell Renderer)FeatureHigh2026-05-23
Data validation list (dropdown)FeatureHigh2026-05-23
Find / replace (Find & Replace)FeatureHigh2026-05-23
Conditional formatting (Conditional Formatting)FeatureLow2026-05-23
Number format engine (Number Format)FeatureHigh2026-05-23
Cell merge (Merge / Unmerge / Merge & Center)FeatureHigh2026-05-22
Cell format + editing toolbarFeatureMedium2026-05-22
Cell protection (read-only)FeatureHigh2026-05-13
Selection aggregation (SUM / AVG / COUNT)FeatureMedium2026-05-13
Cell annotations / tooltipsFeatureMedium2026-05-13
Sheet zoomFeatureMedium2026-05-13
Sort & filterFeatureHigh2026-05-13
Undo / redoFeatureHigh2026-05-13
Fill handle & shortcutsFeatureMedium2026-05-13
Clipboard (TSV)FeatureHigh2026-05-13
Keyboard navigation & context menuFeatureHigh2026-05-13
Row & column manipulationFeatureHigh2026-05-12
Bug fix: column header layout regressionBugHigh2026-05-11
Virtualization & performanceFeatureHigh2026-05-11
Cell editing & validationFeatureHigh2026-05-11
Cell selection systemFeatureHigh2026-05-11
Initial setup of the React library projectFeatureHigh2026-05-11