Skip to content

Rendering

@witqq/spreadsheet renders everything on Canvas 2D instead of creating DOM nodes for each cell. This eliminates the DOM node ceiling and provides consistent 60 FPS performance regardless of dataset size.

The rendering system uses a composable layer architecture. Each layer is responsible for one visual concern and draws onto the canvas in order:

OrderLayerDescription
1BackgroundLayerFills cell backgrounds with alternating row colors
2CellTextLayerRenders cell text content with type-specific formatting
3CellStatusLayerShows cell status indicators (changed, saving, saved, error)
4EmptyStateLayerShows empty state placeholder when no data
5GridLinesLayerDraws horizontal and vertical grid lines (conditional)
6HeaderLayerRenders column headers with sort/filter indicators
7RowNumberLayerDraws row numbers on the left side (conditional)
8SelectionOverlayLayerDraws selection highlight and active cell border

Plugin layers (added via plugins, not part of core):

  • ConditionalFormatLayer — applies conditional formatting (gradients, data bars, icons)
  • RemoteCursorLayer — renders remote user cursors in collaboration mode

Each layer implements the RenderLayer interface:

interface RenderLayer {
render(rc: RenderContext): void;
measureHeights?(rc: RenderContext): Map<number, number>;
}
interface RenderContext {
ctx: CanvasRenderingContext2D;
geometry: GridGeometry;
theme: SpreadsheetTheme;
canvasWidth: number;
canvasHeight: number;
viewport: ViewportRange;
scrollX: number;
scrollY: number;
renderMode: RenderMode;
paneRegion: PaneRegion;
mergeManager?: MergeManager;
}

Not every frame requires a full repaint. The DirtyTracker identifies what changed and triggers the minimum necessary re-render:

Dirty TypeTriggerWhat Re-renders
fullTheme change, resize, data reloadEntire canvas
viewport-changeScroll, frozen pane toggleVisible region only
cell-updateCell edit, status changeSingle cell clip region

For cell-update, the renderer uses ctx.save(), ctx.clip() to restrict drawing to the affected cell rectangle, then runs only the relevant layers.

Multiple operations in a single frame (e.g., a paste affecting 50 cells) would normally trigger 50 render calls. The RenderScheduler coalesces these into a single requestAnimationFrame:

Frame N: setCell(0,0) → setCell(0,1) → setCell(0,2) → ...
↓ ↓ ↓
markDirty() markDirty() markDirty() (coalesced)
Frame N+1: ONE render() call with all dirty regions merged

The ViewportManager calculates which rows and columns are currently visible, plus a buffer zone for smooth scrolling:

interface ViewportRange {
readonly startRow: number; // First visible row (inclusive, with buffer)
readonly endRow: number; // Last visible row (inclusive, with buffer)
readonly startCol: number; // First visible column (inclusive, with buffer)
readonly endCol: number; // Last visible column (inclusive, with buffer)
readonly visibleRowCount: number; // Visible rows including buffer
readonly visibleColCount: number; // Visible columns including buffer
}

Only cells within the viewport (plus buffer) are rendered. For a 100K-row dataset, typically only 30-50 rows are drawn per frame.

The RenderMode type supports 'full' | 'light' | 'placeholder' modes. Currently the mode is always 'full' — all cells are rendered with complete fidelity. The type union exists for future optimization where fast scrolling could switch to simplified rendering.

Text measurement (ctx.measureText()) is expensive. The TextMeasureCache stores results in a Map keyed by font+text:

// Cache hit rate is typically >95% for real spreadsheets
const width = textMeasureCache.measureText(ctx, 'Hello World', '14px Inter');
  • Capacity: 10,000 entries with LRU eviction
  • Text truncation: Uses binary search to find the longest substring that fits in a cell width, then appends "…"

@witqq/spreadsheet renders all layers on a single canvas element. The CanvasManager applies a comprehensive CSS reset (margin, padding, border, outline, box-sizing, etc.) to prevent any framework or parent CSS from displacing the canvas. It also handles DPR (Device Pixel Ratio) scaling and detects browser zoom changes via matchMedia, automatically recalibrating canvas resolution.

You can add custom layers to the pipeline or reorder existing ones:

engine.addRenderLayer(myCustomLayer);
engine.insertRenderLayerBefore(myCustomLayer, selectionLayer);
engine.removeRenderLayer(emptyStateLayer);
MethodDescription
addRenderLayer(layer)Append layer at end of pipeline
insertRenderLayerBefore(layer, beforeLayer)Insert before an existing layer
removeRenderLayer(layer)Remove a layer

When frozen rows or columns are enabled, the render pipeline splits rendering into 4 regions:

Frozen regions are cached as ImageData and only re-rendered when their content changes, not on every scroll frame.