Sparkline cell renderer
In this tutorial, you will build a custom cell renderer that draws an inline SVG bar chart from an array of numbers in each cell. You will learn how to register a named renderer with registerRenderer, bundle it into a cell type with registerCellType, and assign it to a read-only sparkline column.
import Handsontable from 'handsontable/base';import { registerAllModules } from 'handsontable/registry';import { registerRenderer, baseRenderer } from 'handsontable/renderers';import { registerCellType } from 'handsontable/cellTypes';
registerAllModules();
const SLOT = 10;const GAP = 2;const VIEW_HEIGHT = 100;const WEEK_KEYS = ['w1', 'w2', 'w3', 'w4', 'w5'];const VIEW_WIDTH = WEEK_KEYS.length * SLOT - GAP;
// Returns null for invalid/non-numeric values, preserving each slot's position.function toSlots(rowData) { return WEEK_KEYS.map((key) => { const value = rowData?.[key]; const n = typeof value === 'number' ? value : Number(value); return Number.isFinite(n) ? n : null; });}
// Inline SVG bar chart generated from the row's w1-w5 values.const sparklineRenderer = (instance, td, row, col, prop, value, cellProperties) => { baseRenderer(instance, td, row, col, prop, value, cellProperties);
const sourceRow = instance.getSourceDataAtRow(row); const slots = toSlots(sourceRow); const validNumbers = slots.filter((n) => n !== null);
if (validNumbers.length === 0) { td.textContent = '—'; td.title = 'No data'; return; }
const rowMax = validNumbers.reduce((m, n) => Math.max(m, Math.abs(n)), 0);
if (rowMax === 0) { td.textContent = '—'; td.title = 'All values are zero'; return; }
const average = validNumbers.reduce((sum, n) => sum + n, 0) / validNumbers.length; const rects = slots .map((n, i) => { if (n === null) return ''; const barHeight = (Math.abs(n) / rowMax) * VIEW_HEIGHT; const x = i * SLOT; const y = VIEW_HEIGHT - barHeight; const w = SLOT - GAP; const fill = n >= average ? '#16a34a' : '#dc2626'; return `<rect x="${x}" y="${y}" width="${w}" height="${barHeight}" fill="${fill}"/>`; }) .join('');
td.innerHTML = `<svg width="100%" height="100%" viewBox="0 0 ${VIEW_WIDTH} ${VIEW_HEIGHT}" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">${rects}</svg>`; td.removeAttribute('title');};
registerRenderer('sparklineBar', sparklineRenderer);registerCellType('sparklineBar', { renderer: sparklineRenderer });
/* start:skip-in-preview */const data = [ { product: 'Desk lamp', w1: 4, w2: 8, w3: 2, w4: 9, w5: 5 }, { product: 'Monitor arm', w1: 1, w2: 1, w3: 0, w4: 0, w5: 0 }, { product: 'USB hub', w1: null, w2: null, w3: null, w4: null, w5: null }, { product: 'Mouse', w1: undefined, w2: undefined, w3: 3, w4: 5, w5: 2 }, { product: 'Keyboard', w1: 3, w2: 6, w3: 7, w4: 4, w5: 8 }, { product: 'Webcam', w1: 0, w2: 0, w3: 0, w4: 0, w5: 0 },];/* end:skip-in-preview */
const container = document.querySelector('#example1');
new Handsontable(container, { data, rowHeaders: true, rowHeights: 44, colHeaders: ['Product', 'W1', 'W2', 'W3', 'W4', 'W5', 'Sparkline'], licenseKey: 'non-commercial-and-evaluation', height: 'auto', width: '100%', columns: [ { data: 'product', type: 'text', width: 100, readOnly: true }, { data: 'w1', type: 'numeric', width: 48 }, { data: 'w2', type: 'numeric', width: 48 }, { data: 'w3', type: 'numeric', width: 48 }, { data: 'w4', type: 'numeric', width: 48 }, { data: 'w5', type: 'numeric', width: 48 }, { data: null, width: 160, type: 'sparklineBar', className: 'htMiddle sparkline-cell', readOnly: true, }, ],});import Handsontable from 'handsontable/base';import { registerAllModules } from 'handsontable/registry';import { BaseRenderer, registerRenderer, baseRenderer } from 'handsontable/renderers';import { registerCellType } from 'handsontable/cellTypes';
registerAllModules();
const SLOT = 10;const GAP = 2;const VIEW_HEIGHT = 100;const WEEK_KEYS = ['w1', 'w2', 'w3', 'w4', 'w5'] as const;const VIEW_WIDTH = WEEK_KEYS.length * SLOT - GAP;
// Returns null for invalid/non-numeric values, preserving each slot's position.function toSlots(rowData: Record<string, unknown> | null): (number | null)[] { return WEEK_KEYS.map((key) => { const value = rowData?.[key]; const n = typeof value === 'number' ? value : Number(value); return Number.isFinite(n) ? n : null; });}
// Inline SVG bar chart generated from the row's w1-w5 values.const sparklineRenderer: BaseRenderer = ( instance, td, row, col, prop, value, cellProperties) => { baseRenderer(instance, td, row, col, prop, value, cellProperties);
const sourceRow = instance.getSourceDataAtRow( row ) as Record<string, unknown> | null; const slots = toSlots(sourceRow); const validNumbers = slots.filter((n): n is number => n !== null);
if (validNumbers.length === 0) { td.textContent = '—'; td.title = 'No data'; return; }
const rowMax = validNumbers.reduce((m, n) => Math.max(m, Math.abs(n)), 0);
if (rowMax === 0) { td.textContent = '—'; td.title = 'All values are zero'; return; }
const average = validNumbers.reduce((sum, n) => sum + n, 0) / validNumbers.length; const rects = slots .map((n, i) => { if (n === null) return ''; const barHeight = (Math.abs(n) / rowMax) * VIEW_HEIGHT; const x = i * SLOT; const y = VIEW_HEIGHT - barHeight; const w = SLOT - GAP; const fill = n >= average ? '#16a34a' : '#dc2626'; return `<rect x="${x}" y="${y}" width="${w}" height="${barHeight}" fill="${fill}"/>`; }) .join('');
td.innerHTML = `<svg width="100%" height="100%" viewBox="0 0 ${VIEW_WIDTH} ${VIEW_HEIGHT}" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">${rects}</svg>`; td.removeAttribute('title');};
registerRenderer('sparklineBar', sparklineRenderer);registerCellType('sparklineBar', { renderer: sparklineRenderer });
/* start:skip-in-preview */const data = [ { product: 'Desk lamp', w1: 4, w2: 8, w3: 2, w4: 9, w5: 5 }, { product: 'Monitor arm', w1: 1, w2: 1, w3: 0, w4: 0, w5: 0 }, { product: 'USB hub', w1: null, w2: null, w3: null, w4: null, w5: null }, { product: 'Mouse', w1: undefined, w2: undefined, w3: 3, w4: 5, w5: 2 }, { product: 'Keyboard', w1: 3, w2: 6, w3: 7, w4: 4, w5: 8 }, { product: 'Webcam', w1: 0, w2: 0, w3: 0, w4: 0, w5: 0 },];/* end:skip-in-preview */
const container = document.querySelector('#example1')!;
new Handsontable(container, { data, rowHeaders: true, rowHeights: 44, colHeaders: ['Product', 'W1', 'W2', 'W3', 'W4', 'W5', 'Sparkline'], licenseKey: 'non-commercial-and-evaluation', height: 'auto', width: '100%', columns: [ { data: 'product', type: 'text', width: 100, readOnly: true }, { data: 'w1', type: 'numeric', width: 48 }, { data: 'w2', type: 'numeric', width: 48 }, { data: 'w3', type: 'numeric', width: 48 }, { data: 'w4', type: 'numeric', width: 48 }, { data: 'w5', type: 'numeric', width: 48 }, { data: null, width: 160, type: 'sparklineBar', className: 'htMiddle sparkline-cell', readOnly: true, }, ],});/* Keep sparkline cells vertically centered and tall enough for the SVG */#example1 .handsontable td.sparkline-cell { vertical-align: middle;}Overview
This recipe shows how to edit weekly values in table cells and render a mini bar chart with inline SVG in a separate sparkline column. No charting library is required.
Difficulty: Beginner
Time: ~10 minutes
Libraries: None
What you’ll build
- A named renderer registered with
registerRendererand bundled as asparklineBarcell type viaregisterCellType. - Bars scaled to a fixed global scale — the tallest bar across the entire dataset fills the full SVG height. Editing one cell does not change proportions in other rows.
- Each row always shows five bar slots. A slot is left empty when its source value is missing or non-numeric.
- Safe handling when a row has no valid values or when every value in the dataset is zero.
- Optional coloring: green when a value is at or above the row average, red when below.
- Interactive behavior — when you edit W1-W5, Handsontable re-renders the sparkline SVG for that row.
Call the base renderer first
Always call
baseRendererbefore you change the cell content. That keeps read-only styling, validation classes, and ARIA attributes consistent with other cells.Map cell values to slots
Use
toSlotsto map each of the five week keys to either a number ornull. Returningnullinstead of filtering keeps the slot count fixed at five so the SVG viewBox width never changes. Invalid or missing values produce an empty slot rather than a shifted bar.Compute a global scale
Call
getGlobalMaxto find the largest absolute value across all rows in the dataset. All bars share this scale, so editing a single cell only changes that row’s bar heights — not the proportions in every other row. A global max of zero means every value in the dataset is zero, which is handled as an edge case.Build the SVG
Use one
<rect>per valid slot. Bar height is(abs(value) / globalMax) * viewBoxHeight. The viewBox is always(5 × SLOT - GAP)wide, so empty slots stay as blank space at their correct position. Average the valid numbers in the row to pick fill colors.Register the renderer and cell type
Use
registerRenderer('sparklineBar', sparklineRenderer)to name the renderer, thenregisterCellType('sparklineBar', { renderer: 'sparklineBar' })to bundle it as a cell type. Settype: 'sparklineBar'on the sparkline column instead ofrenderer: 'sparklineBar'. Keep that columnreadOnlyand leave W1-W5 editable so each edit triggers a fresh render.
Edge cases covered
| Case | Behavior |
|---|---|
null / undefined / non-numeric week values | Empty slot — bar is omitted, neighboring bars keep their positions |
| All week values missing in a row | Em dash, tooltip “No data” |
| All values zero across the entire dataset | Em dash, tooltip “All values are zero” |
| Mixed valid numbers | Bars scale to the global maximum absolute value |
What you learned
- How to register a named custom renderer with
registerRendererand bundle it as a reusable cell type withregisterCellType. - How to compute a global scale from the full dataset so bars stay proportional across rows when data changes.
- How to preserve fixed slot positions by returning
nullfor invalid values instead of filtering them out. - How to handle edge cases — null values, all-zero datasets, and empty rows — so the renderer degrades gracefully to a placeholder instead of crashing.
- How keeping the sparkline column
readOnlywhile leaving the data columns editable triggers a fresh render with updated bars after each edit.
Next steps
- Explore conditional row coloring for a simpler styling approach using CSS classes instead of custom SVG output.
- Read the cell renderer guide for the full renderer API and lifecycle.
Related
- Cell renderer - renderer API and registration patterns.