Skip to content

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.

JavaScript
import { HotTable } from '@handsontable/react-wrapper';
import { registerAllModules } from 'handsontable/registry';
import { registerRenderer, baseRenderer } from 'handsontable/renderers';
import { registerCellType } from 'handsontable/cellTypes';
import './example1.css';
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 ExampleComponent = () => {
return (
<HotTable
id="example1"
data={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,
},
]}
/>
);
};
export default ExampleComponent;
TypeScript
import { HotTable } from '@handsontable/react-wrapper';
import { registerAllModules } from 'handsontable/registry';
import { BaseRenderer, registerRenderer, baseRenderer } from 'handsontable/renderers';
import { registerCellType } from 'handsontable/cellTypes';
import './example1.css';
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 ExampleComponent = () => {
return (
<HotTable
id="example1"
data={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,
},
]}
/>
);
};
export default ExampleComponent;
CSS
/* 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 registerRenderer and bundled as a sparklineBar cell type via registerCellType.
  • 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.
  1. Call the base renderer first

    Always call baseRenderer before you change the cell content. That keeps read-only styling, validation classes, and ARIA attributes consistent with other cells.

  2. Map cell values to slots

    Use toSlots to map each of the five week keys to either a number or null. Returning null instead 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.

  3. Compute a global scale

    Call getGlobalMax to 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.

  4. 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.

  5. Register the renderer and cell type

    Use registerRenderer('sparklineBar', sparklineRenderer) to name the renderer, then registerCellType('sparklineBar', { renderer: 'sparklineBar' }) to bundle it as a cell type. Set type: 'sparklineBar' on the sparkline column instead of renderer: 'sparklineBar'. Keep that column readOnly and leave W1-W5 editable so each edit triggers a fresh render.

Edge cases covered

CaseBehavior
null / undefined / non-numeric week valuesEmpty slot — bar is omitted, neighboring bars keep their positions
All week values missing in a rowEm dash, tooltip “No data”
All values zero across the entire datasetEm dash, tooltip “All values are zero”
Mixed valid numbersBars scale to the global maximum absolute value

What you learned

  • How to register a named custom renderer with registerRenderer and bundle it as a reusable cell type with registerCellType.
  • 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 null for 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 readOnly while leaving the data columns editable triggers a fresh render with updated bars after each edit.

Next steps