@taskctrl/canvas-grid
v0.1.0
Published
Tree-structured, virtualized data grid rendered on stacked HTML canvas layers for React
Readme
@taskctrl/canvas-grid
A React data grid that renders to stacked HTML <canvas> layers instead of DOM cells. Built for dense, tree-structured matrices (think a status grid of systems × disciplines) where thousands of cells would make a DOM grid sluggish.
- Canvas rendering — cells are drawn imperatively, so cell count barely affects performance.
- Tree rows — parent/child nesting with expand/collapse, shown in a sidebar or hosted on a pinned column.
- Pinned & scrollable columns, column groups, resize, reorder, and show/hide.
- Virtualized — only the visible row/column range is drawn.
- Selection — single, ctrl-toggle, and shift-rectangle, plus full keyboard navigation.
- Accessible —
role="grid", ARIA live announcements, keyboard-driven. - Export to image — render the full grid to an offscreen canvas at retina scale.
Install
yarn add @taskctrl/canvas-grid
# or
npm install @taskctrl/canvas-gridreact and react-dom (>=17) are peer dependencies.
Usage
You provide three things: the rows/columns shape, a getCellData function that returns the raw value for a cell, and a cellRenderer that imperatively draws that cell onto the canvas.
import { CanvasGrid } from '@taskctrl/canvas-grid'
import type { Row, Column, CellRenderer } from '@taskctrl/canvas-grid'
const rows: Row[] = [
{ id: 'floor1', title: 'Floor 1' },
{ id: 'sys101', parentId: 'floor1', title: '101 - Main Panel' },
{ id: 'sys102', parentId: 'floor1', title: '102 - Sub Panel A' },
]
const columns: Column[] = [
{ id: 'power', title: 'Power', width: 60 },
{ id: 'vent', title: 'Ventilation', width: 60 },
]
const statusColors: Record<string, string> = {
complete: '#22c55e',
in_progress: '#f59e0b',
issue: '#ef4444',
}
const getCellData = (rowId: string | number, colId: string) => {
return { status: 'complete' } // look up your real data here
}
const cellRenderer: CellRenderer = (ctx, cell, bounds, state, helpers) => {
const status = (cell.data as { status: string })?.status ?? 'empty'
const color = statusColors[status] ?? '#e5e7eb'
const size = Math.min(bounds.width, bounds.height) - 12
const x = bounds.x + (bounds.width - size) / 2
const y = bounds.y + (bounds.height - size) / 2
helpers.roundRect(x, y, size, size, 3, color)
}
function Grid() {
return (
<div style={{ width: '100%', height: 500 }}>
<CanvasGrid
rows={rows}
columns={columns}
getCellData={getCellData}
cellRenderer={cellRenderer}
defaultExpandedRows={['floor1']}
/>
</div>
)
}The grid sizes itself to its content but is capped at the dimensions of its parent, so wrap it in a sized container.
The cell renderer
cellRenderer(ctx, cell, bounds, state, helpers) is called for every visible cell:
ctx— theCanvasRenderingContext2D(already clipped to the cell rect).cell—{ rowId, colId, data }, wheredatais whatevergetCellDatareturned.bounds—{ x, y, width, height }of the cell in canvas pixels.state—{ selected, hovered, rowSelected, colSelected, depth, hasChildren, expanded, ... }.helpers— drawing primitives so you don't reach for raw canvas APIs:
| Helper | Purpose |
| --- | --- |
| roundRect(x, y, w, h, r, fill) | filled rounded rectangle |
| fillText(text, x, y, maxWidth?) | draw text |
| truncateText(text, maxWidth) | returns text truncated with an ellipsis to fit |
| badge(x, y, text, bg, fg) | pill badge, returns its width |
| progressBar(x, y, w, h, percent, color) | track + fill bar (percent 0–1) |
Tree rows
A row becomes a child by setting parentId to another row's id. defaultExpandedRows sets the initially expanded rows. Without pinned columns the tree is shown in the left sidebar; if any column is pinned: 'left', the first pinned column hosts the expand/collapse toggle instead and the sidebar is hidden.
Expansion is managed internally by default. Pass onTreeToggle to take over toggling, or onExpandChange to observe it.
Pinned columns & groups
const columns: Column[] = [
{ id: 'name', title: 'System', width: 200, pinned: 'left' },
{ id: 'power', title: 'Power', width: 60, group: 'electrical' },
{ id: 'vent', title: 'Ventilation', width: 60, group: 'hvac' },
]
const columnGroups = [
{ id: 'electrical', title: 'Electrical', color: '#6366f1' },
{ id: 'hvac', title: 'HVAC', color: '#059669' },
]
<CanvasGrid columns={columns} columnGroups={columnGroups} ... />Export to image
Grab a ref and call captureToCanvas() to render the entire grid (not just the visible range) to an offscreen <canvas>, e.g. for screenshots or printing:
import { useRef } from 'react'
import { CanvasGrid } from '@taskctrl/canvas-grid'
import type { CanvasGridRef } from '@taskctrl/canvas-grid'
const ref = useRef<CanvasGridRef>(null)
// later:
const canvas = ref.current!.captureToCanvas({ scale: 2 }) // 2 = retina
const dataUrl = canvas.toDataURL('image/png')
<CanvasGrid ref={ref} ... />Props
Common props (see the exported CanvasGridProps type for the full list):
| Prop | Type | Notes |
| --- | --- | --- |
| rows | Row[] | { id, parentId?, ...your fields } |
| columns | Column[] | { id, title, width, pinned?, group?, hidden?, minWidth? } |
| getCellData | (rowId, colId) => unknown | raw value passed to the renderer |
| cellRenderer | CellRenderer | draws each cell |
| columnGroups? | ColumnGroup[] | banded header above columns |
| sidebarRenderer? | render the tree column yourself |
| rowHeight? | number | default 32 |
| sidebarWidth? | number | default 180 (ignored when columns are pinned) |
| headerOrientation? | 'horizontal' \| 'vertical' | vertical headers are taller |
| selection / onSelectionChange | controlled selection |
| onCellClick / onCellDoubleClick / onCellContextMenu / onCellHover | cell events |
| onColumnResize / onColumnReorder / onColumnVisibilityChange | column edits (the grid also applies them locally) |
| onLoadMore / hasMore / loading | infinite scroll near the bottom |
| showColumnVisibilityMenu? | built-in column show/hide menu |
Keyboard
Arrow keys move the selection; Shift+Arrow extends a rectangle; Tab/Shift+Tab move with row wrap; Home/End jump to row start/end (with Ctrl for grid corners); Ctrl/Cmd+A selects all; Esc clears.
Development
yarn install
yarn test # Vitest (jsdom)
yarn test:watch
yarn storybook dev -p 6006 # interactive examples in stories/
yarn build # library build -> dist/ (ES + CJS + .d.ts)See CLAUDE.md for an architecture overview (the three-layer canvas, the core engines, and the render scheduler).
License
UNLICENSED — internal taskctrl package.
