High-performance grid/spreadsheet library for the web. Zero dependencies, TypeScript, DOM-virtualized.
- Two modes: Spreadsheet (independent cells) and Table (column-driven rows)
- Two layouts:
scroll(fixed height, internal scroll, virtualized) andauto(grows with content, page scroll) - DOM virtualized: Only visible cells exist in the DOM (~750 nodes max). Recycled on scroll.
- Rich cell content: Each cell is a real
<div>- any HTML, CSS, hover effects, transitions - Renderers: text, number, checkbox, badge, progress, blank - or register your own
- Editors: text, dropdown (async, searchable, multi-select), blank - or register your own
- Cell-level types: Each cell can have its own renderer/editor independent of the column
- Selection: Single, range (Shift), multi-range (Ctrl), full row/column, select all
- Keyboard navigation: Arrows, Tab, Enter, Escape, Home/End, Ctrl+arrows, typing to edit
- Clipboard: Copy/paste/cut as TSV (compatible with Excel/Sheets). Single-cell paste fills selection.
- Undo/Redo: Command pattern with configurable stack limit
- Sorting: Per-column, asc/desc, custom comparator, external callback for server-side
- Filtering: Per-column, custom filter functions, external callback for server-side
- Merge cells: Merge/unmerge arbitrary ranges
- Frozen panes: Sticky rows and columns that stay visible on scroll
- Grouped headers: Multi-level column headers (e.g. "Months / Jan Feb Mar")
- Pagination: Built-in pagination bar with page size, total rows, navigation
- Context menu: Dynamic items with
visible/disabledas callbacks, per-target (cell/column/row header) - Resize: Column and row resize by dragging header edges (individually toggleable)
- Auto-fit: Columns and rows auto-size to content
- Auto row height: Rows automatically grow to fit cell content
- Export: CSV and JSON with download, configurable columns/headers
- Themes: Light, Dark, Minimal presets - or define your own via CSS variables
- Loading overlay: Show/hide spinner overlay for async operations
- Events: Typed event system for cell changes, selection, scroll, commands, editor lifecycle
- Command events:
command:execute,command:undo,command:redofor syncing with external databases - Fill handle: Drag to fill cells in any direction (including diagonal), with custom callback
- Column reorder: Drag headers to reorder columns
- Row grouping: Expand/collapse groups of rows
- Infinite mode: Grid auto-expands on navigate/scroll beyond bounds
- Lazy loading:
onLoadMorecallback when scroll reaches the edge - Sparse data model: Map-based, only cells with data consume memory. Patches for partial updates.
npm install wandertableimport { WanderTable } from 'wandertable';
import 'wandertable/style.css';
const table = new WanderTable(document.getElementById('grid'), {
mode: 'spreadsheet',
rowCount: 1000,
colCount: 26,
showRowHeaders: true,
showColHeaders: true,
});
// Set individual cells
table.setCellValue(0, 0, 'Hello');
table.setCellValue(0, 1, 42);
// Bulk load
table.applyPatch([
{ row: 1, col: 0, data: { value: 'World', style: { fontWeight: 'bold' } } },
{ row: 1, col: 1, data: { value: true, type: 'checkbox' } },
{ row: 2, col: 0, data: { value: 75, type: 'progress' } },
{ row: 2, col: 1, data: { value: 'active', type: 'badge' } },
]);const table = new WanderTable(document.getElementById('grid'), {
mode: 'table',
layout: 'auto', // grows with content, scroll on page
autoRowHeight: true,
columns: [
{ key: 'name', label: 'Name', width: 180 },
{ key: 'email', label: 'Email', width: 200 },
{ key: 'status', label: 'Status', type: 'badge', width: 100 },
{ key: 'active', label: 'Active', type: 'checkbox', width: 70 },
],
tableData: [
{ name: 'Alice', email: 'alice@test.com', status: 'active', active: true },
{ name: 'Bob', email: 'bob@test.com', status: 'pending', active: false },
],
});const table = new WanderTable(el, {
mode: 'table',
columns: [
{ key: 'name', label: 'Name', width: 180 },
{
label: 'Q4 Sales',
children: [
{ key: 'oct', label: 'Oct', type: 'number', width: 80 },
{ key: 'nov', label: 'Nov', type: 'number', width: 80 },
{ key: 'dec', label: 'Dec', type: 'number', width: 80 },
],
},
{
label: 'Metrics',
children: [
{ key: 'score', label: 'Score', type: 'progress', width: 120 },
{ key: 'active', label: 'Active', type: 'checkbox', width: 70 },
],
},
],
tableData: [...],
});new WanderTable(container, {
// ── Mode & Layout ──
mode: 'spreadsheet' | 'table', // default: 'spreadsheet'
layout: 'scroll' | 'auto', // default: 'scroll'
// ── Data ──
data: CellPatch[], // initial cell patches (spreadsheet mode)
tableData: Record<string, unknown>[], // initial row objects (table mode)
columns: ColumnConfig[], // column definitions
rowCount: number, // default: 100
colCount: number, // default: 26
// ── Dimensions ──
defaultRowHeight: number, // default: 28
defaultColWidth: number, // default: 100
autoRowHeight: boolean, // auto-fit row height to content. default: false
// ── Display ──
showRowHeaders: boolean, // show row numbers. default: true
showColHeaders: boolean, // show column letters/labels. default: true
showGridLines: boolean, // show cell borders. default: true
showBorder: boolean, // show outer border. default: true
loading: boolean, // show loading overlay. default: false
// ── Frozen Panes ──
frozenRows: number, // sticky rows at top. default: 0
frozenCols: number, // sticky columns at left. default: 0
// ── Permissions ──
readOnly: boolean, // no editing at all. default: false
allowEditing: boolean, // allow cell editing. default: true
allowSelection: boolean, // allow cell selection. default: true
allowRangeSelection: boolean, // allow multi-cell selection. default: true
allowColResize: boolean, // column resize by drag. default: true
allowRowResize: boolean, // row resize by drag. default: true
allowClipboard: boolean, // copy/paste/cut. default: true
allowContextMenu: boolean, // right-click menu. default: true
allowKeyboardNav: boolean, // keyboard navigation. default: true
allowUndoRedo: boolean, // undo/redo. default: true
// ── Cell Defaults ──
defaultCellType: string, // renderer for cells without type. default: 'text'
undoLimit: number, // max undo stack size. default: 100
// ── Merge & Sort & Filter ──
merges: MergeRange[], // initial merged ranges
sort: { col, direction }, // initial sort
filters: Record<number, FilterFn>, // initial filters
pagination: PaginationConfig, // pagination settings
// ── Theme ──
theme: Partial<ThemeConfig>, // or use THEME_DARK, THEME_MINIMAL
// ── Callbacks ──
onCellChange: (row, col, oldValue, newValue) => void,
onSelectionChange: (ranges) => void,
onScroll: (viewport) => void,
onContextMenu: (row, col, event) => void,
onSort: (col, direction, key?) => boolean | void, // return true for external sort
onFilter: (col, key, active) => boolean | void, // return true for external filter
onPageChange: (page, pageSize) => void,
});table.setCell(row, col, cellData) // set full cell data
table.getCell(row, col) // get cell data
table.setCellValue(row, col, value) // set just the value (with undo)
table.deleteCell(row, col) // remove cell
table.applyPatch(patches) // bulk update (with undo)
table.transaction(() => { ... }) // batch changes, single re-render
table.setTableData(rows) // replace all data (table mode)table.getSelection() // get selected ranges
table.getSelectionInfo() // rich info: focus, bounds, cellCount, isFullRow, etc.
table.setSelection(ranges) // set selection programmatically
table.scrollTo(row, col) // scroll to make cell visibletable.undo()
table.redo()
table.commands.canUndo // boolean
table.commands.canRedo // boolean
table.commands.clear() // clear historytable.sort(col, 'asc' | 'desc' | null, compareFn?)
table.clearSort()
table.getSortState()
table.setFilter(col, (row, data) => boolean)
table.removeFilter(col)
table.clearFilters()
table.getActiveFilters() // column indices with active filterstable.mergeCells(row, col, rowSpan, colSpan)
table.unmergeCells(row, col)
table.getMerge(row, col) // get merge at cell
table.clearMerges()table.setColumnWidth(col, width)
table.setRowHeight(row, height)
table.autoFitColumn(col) // auto-size to content
table.autoFitRow(row) // auto-size to content
table.insertRow(at)
table.insertColumn(at)
table.refresh() // force re-rendertable.exportCSV(options?) // returns CSV string
table.exportJSON(options?) // returns object[]
table.downloadCSV('file.csv', options?)
table.downloadJSON('file.json', options?)Options: { visibleOnly, columns, includeHeaders, headers }
table.setPage(page) // 0-based
table.setPageSize(size)
table.getPaginationState() // { page, pageSize, totalRows, totalPages, startRow, endRow }
table.pagination.nextPage()
table.pagination.prevPage()
table.pagination.firstPage()
table.pagination.lastPage()table.setLoading(true) // show spinner overlay
table.setLoading(false) // hide
table.isLoading // boolean// Register custom renderer
table.registerRenderer('my-type', {
render(cell: HTMLElement, data: CellData | undefined, state: CellState) {
cell.innerHTML = `<div class="custom">${data?.value ?? ''}</div>`;
}
});
// Register custom editor
table.registerEditor('my-editor', () => ({
createElement(container) { ... },
open(value, bounds, cellData) { ... },
getValue() { ... },
close() { ... },
}));
// Use per cell
table.setCell(0, 0, { value: 'hello', type: 'my-type' });
// Or per column
columns: [{ key: 'status', type: 'my-type', editor: 'my-editor' }]Built-in renderers: text, number, checkbox, badge, progress, blank
// Override menu items
table.contextMenuItems = [
{
label: 'My Action',
shortcut: 'Ctrl+M',
visible: (ctx) => ctx.target === 'cell', // show only for cells
disabled: (ctx) => ctx.readOnly, // disable when readOnly
action: (ctx) => console.log(ctx.row, ctx.col),
},
{ label: '', separator: true },
{
label: 'Column Action',
visible: (ctx) => ctx.target === 'column-header',
action: (ctx) => console.log('col', ctx.col),
},
];MenuContext fields: target, row, col, selectedCount, isMultiSelect, isFullRow, isFullCol, readOnly, canUndo, canRedo
table.on('cell:change', ({ row, col, oldValue, newValue }) => { ... })
table.on('cell:click', ({ row, col, event }) => { ... })
table.on('cell:dblclick', ({ row, col, event }) => { ... })
table.on('cell:contextmenu', ({ row, col, event }) => { ... })
table.on('selection:change', ({ ranges }) => { ... })
table.on('editor:open', ({ row, col }) => { ... })
table.on('editor:close', ({ row, col, value, cancelled }) => { ... })
table.on('scroll', ({ viewport }) => { ... })
table.on('column:resize', ({ col, width }) => { ... })
table.on('row:resize', ({ row, height }) => { ... })
table.on('data:patch', ({ patches }) => { ... })
table.on('command:execute', ({ changes }) => { ... }) // sync with DB
table.on('command:undo', ({ changes }) => { ... })
table.on('command:redo', ({ changes }) => { ... })
// Unsubscribe
const unsub = table.on('cell:change', handler);
unsub();import { THEME_LIGHT, THEME_DARK, THEME_MINIMAL } from 'wandertable';
new WanderTable(el, { theme: THEME_DARK });Or define your own via CSS variables:
.wt-root {
--wt-bg: #0f172a;
--wt-cell-bg: #1e293b;
--wt-cell-text: #e2e8f0;
--wt-header-bg: #0f172a;
--wt-header-text: #94a3b8;
--wt-grid-line: #334155;
--wt-sel-bg: rgba(59, 130, 246, 0.15);
--wt-sel-border: #3b82f6;
--wt-focus-border: #60a5fa;
--wt-font: 'Inter', sans-serif;
--wt-font-size: 13px;
--wt-header-font-size: 12px;
--wt-cell-padding: 8px;
--wt-header-height: 28px;
--wt-row-header-width: 50px;
}Drag the small square at the bottom-right corner of a selection to fill cells.
new WanderTable(el, {
allowFillHandle: true, // default: true
// Optional: override default fill behavior
onFill: (source, fill, direction) => {
// direction: 'down' | 'up' | 'right' | 'left' | 'diagonal'
// Return CellPatch[] for custom values
// Return true to cancel fill
// Return void to use default (repeat pattern)
},
});new WanderTable(el, {
allowColReorder: true, // drag column headers to reorder
});
table.reorderColumn(fromCol, toCol); // programmaticnew WanderTable(el, {
rowGroups: [
{ startRow: 0, count: 5, label: 'Group 1', expanded: true },
{ startRow: 6, count: 3, label: 'Group 2', expanded: false },
],
});
table.addRowGroup(startRow, count, label);
table.toggleRowGroup(startRow);
table.expandAllGroups();
table.collapseAllGroups();new WanderTable(el, {
onLoadMore: (direction) => {
// direction: 'down' | 'right'
fetchMoreData().then(rows => {
table.applyPatch(rows);
});
},
});new WanderTable(el, {
infinite: true, // grid expands as you navigate/scroll beyond bounds
rowCount: 50, // initial size
colCount: 26,
});import { columnLabel } from 'wandertable';
columnLabel(0) // 'A'
columnLabel(25) // 'Z'
columnLabel(26) // 'AA'
columnLabel(701) // 'ZZ'
columnLabel(702) // 'AAA'| File | Size | Description |
|---|---|---|
dist/wandertable.js |
~112 KB | ESM module |
dist/wandertable.cjs |
~85 KB | CommonJS module |
dist/wandertable.umd.js |
~85 KB | UMD module |
dist/wandertable.global.js |
~85 KB | IIFE standalone (window.WanderTable) |
dist/style.css |
~8 KB | Styles |
dist/types/ |
TypeScript declarations |
npm install
npm run dev # dev server at http://localhost:5173
npm run build # build library (ESM + CJS + types + CSS)
npm run typecheck # type check
npm run test # run 47 unit tests
npm run test:watch # tests in watch mode- CI (
.github/workflows/ci.yml): Runs on push/PR to main. Typecheck + tests + build on Node 20 and 22. - Publish (
.github/workflows/publish.yml): Publishes to npm on GitHub release. RequiresNPM_TOKENsecret.
MIT