Custom cell renderers
Render anything in a cell such as charts, dropdowns, color and date pickers, and action buttons through a tiny stateless renderer contract that stays highly performant, and swap renderers live.
Each column gets its own renderer through vTrackDefs. The demo below wires seven:
a bold text label, a d3 sparkline, a status <select>, a color <input>, a date
<input>, a progress bar, and an action button. The interactive ones write their
edit straight back to the row data and re-render, so the value on screen is always
a reflection of the data, never of the DOM.
// Charts: the cell value is a number[]; d3 turns it into an inline SVG.
import { line, curveCatmullRom } from "d3-shape";
import { scaleLinear } from "d3-scale";
const trendRenderer: CellRenderer<number[]> = (series, dataCtx) => {
const x = scaleLinear().domain([0, series.length - 1]).range([3, 117]);
const y = scaleLinear().domain([Math.min(...series), Math.max(...series)]).range([29, 3]);
const path = line<number>().x((_, i) => x(i)).y((v) => y(v)).curve(curveCatmullRom)(series);
return `<svg width="120" height="32"><path d="${path}" fill="none" stroke="${rows[dataCtx.rowIndex].color}" /></svg>`;
};
// Editable: seed the widget from the value, write the edit back to the row, re-render.
const statusRenderer: CellRenderer<string> = (value, dataCtx) => {
const select = buildSelect(STATUSES, value);
select.addEventListener("change", () => {
rows[dataCtx.rowIndex].status = select.value; // state lives in the data
render(); // updateData + draw
});
return select;
};Try it below: change a Status, pick a Label color, set a Due date, or click Advance to bump a row's progress. Each edit updates the row and the grid redraws from it.
Source
import Grid, { FlattenedDataViewModel } from "grid/dist/renderer";
import type { CellRenderer, ValueCellDataContext, VTrackDef } from "grid/dist/renderer";
import { scaleLinear } from "d3-scale";
import { line, area, curveCatmullRom } from "d3-shape";
import { createGridMount } from "../../runtime/mount";
import { createToolbar, toolbarSelect } from "../../runtime/toolbar";
import { syncGridTheme } from "../../runtime/theme";
type ChartType = "line" | "bar" | "area";
type Status = "Todo" | "In progress" | "Blocked" | "Done";
const STATUSES: Status[] = ["Todo", "In progress", "Blocked", "Done"];
interface Row {
name: string;
trend: number[];
status: Status;
color: string;
due: string;
progress: number;
}
const GRID_HEIGHT = 460;
const ROW_HEIGHT = 46;
export function mount(el: HTMLElement): () => void {
const rows = generateRows();
// Read live by the chart renderer; a plain draw() repaints every visible chart.
let chartType: ChartType = "line";
// Every renderer stays stateless: it reads the row from `rows` by index and
// writes edits straight back, then `render()` swaps fresh data into the
// viewmodel and redraws. The DOM never holds the source of truth.
const COLUMNS: { label: string; get: (row: Row) => unknown; renderer: CellRenderer<any>; width: number }[] = [
{ label: "Task", width: 1.5, get: (row) => row.name, renderer: nameRenderer },
{ label: "30-day trend", width: 1.2, get: (row) => row.trend, renderer: trendRenderer },
{ label: "Status", width: 1.2, get: (row) => row.status, renderer: statusRenderer },
{ label: "Label color", width: 1.1, get: (row) => row.color, renderer: colorRenderer },
{ label: "Due", width: 1.1, get: (row) => row.due, renderer: dateRenderer },
{ label: "Progress", width: 1.3, get: (row) => row.progress, renderer: progressRenderer },
{ label: "", width: 0.9, get: () => null, renderer: actionRenderer },
];
const toolbar = createToolbar();
const label = document.createElement("span");
label.textContent = "Trend chart";
label.style.cssText = "font-size:12px;opacity:0.7;";
const chartPicker = toolbarSelect(["line", "bar", "area"], chartType);
chartPicker.addEventListener("change", () => {
chartType = chartPicker.value as ChartType;
grid.draw();
});
toolbar.append(label, chartPicker);
const gridMount = createGridMount(el, GRID_HEIGHT);
el.append(toolbar, gridMount);
const grid = new Grid({}, gridMount, "flat");
const disposeTheme = syncGridTheme(grid);
const viewModel = new FlattenedDataViewModel({
data: COLUMNS.map((column) => rows.map((row) => column.get(row))),
columnFacets: [COLUMNS.map((column) => column.label)],
totalRows: rows.length,
options: {
vTrackDefs: COLUMNS.map((column): Partial<VTrackDef> => ({
renderer: column.renderer,
cellHeight: ROW_HEIGHT,
colSize: { strategy: "static", width: column.width, unit: "fr" },
})),
facetDefs: { row: [], col: [{ text: "" }], axis: "col" },
},
});
grid.data = viewModel;
grid.draw();
return () => {
disposeTheme();
el.removeChild(toolbar);
el.removeChild(gridMount);
};
// Re-derive the column-major data from `rows` and push it into the viewmodel.
// Called after any in-cell edit so the renderers redraw from the new state.
function render(): void {
viewModel.updateData({
data: COLUMNS.map((column) => rows.map((row) => column.get(row))),
columnFacets: [COLUMNS.map((column) => column.label)],
totalRows: rows.length,
});
grid.draw();
}
// ----- renderers -----
function nameRenderer(data: string, _dataCtx: ValueCellDataContext, ctx: { container: HTMLElement }): HTMLElement {
ctx.container.style.justifyContent = "flex-start";
ctx.container.style.paddingLeft = "10px";
const span = document.createElement("span");
span.textContent = data;
span.style.cssText = "font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;";
return span;
}
// The cell value is a number[]; d3 turns it into an inline SVG sparkline. The
// chart type is read from scope, so switching it is a pure repaint.
function trendRenderer(data: number[], dataCtx: ValueCellDataContext): string {
return trendSvg(data ?? [], chartType, rows[dataCtx.rowIndex].color);
}
function statusRenderer(data: string, dataCtx: ValueCellDataContext, ctx: { container: HTMLElement }): HTMLElement {
ctx.container.style.padding = "0 8px";
const select = document.createElement("select");
select.style.cssText = fieldStyle + "cursor:pointer;";
for (const status of STATUSES) {
const option = document.createElement("option");
option.value = status;
option.textContent = status;
select.appendChild(option);
}
select.value = data;
select.addEventListener("change", () => {
rows[dataCtx.rowIndex].status = select.value as Status;
render();
});
return select;
}
function colorRenderer(data: string, dataCtx: ValueCellDataContext): HTMLElement {
const wrap = document.createElement("div");
wrap.style.cssText = "display:flex;gap:8px;align-items:center;";
const input = document.createElement("input");
input.type = "color";
input.value = data;
input.style.cssText = "width:24px;height:24px;padding:0;border:none;background:none;cursor:pointer;border-radius:6px;";
input.addEventListener("change", () => {
rows[dataCtx.rowIndex].color = input.value;
render();
});
const hex = document.createElement("span");
hex.textContent = data;
hex.style.cssText = "font-variant-numeric:tabular-nums;font-size:12px;opacity:0.65;";
wrap.append(input, hex);
return wrap;
}
function dateRenderer(data: string, dataCtx: ValueCellDataContext, ctx: { container: HTMLElement }): HTMLElement {
ctx.container.style.padding = "0 8px";
const input = document.createElement("input");
input.type = "date";
input.value = data;
input.style.cssText = fieldStyle + "cursor:text;";
input.addEventListener("change", () => {
rows[dataCtx.rowIndex].due = input.value;
render();
});
return input;
}
function progressRenderer(data: number, dataCtx: ValueCellDataContext, ctx: { container: HTMLElement }): HTMLElement {
ctx.container.style.padding = "0 10px";
const pct = Math.max(0, Math.min(100, data));
const wrap = document.createElement("div");
wrap.style.cssText = "display:flex;gap:8px;align-items:center;width:100%;";
const track = document.createElement("div");
track.style.cssText = "flex:1;height:6px;border-radius:3px;overflow:hidden;background:color-mix(in srgb, currentColor 14%, transparent);";
const fill = document.createElement("div");
fill.style.cssText = `width:${pct}%;height:100%;border-radius:3px;background:${rows[dataCtx.rowIndex].color};`;
track.appendChild(fill);
const num = document.createElement("span");
num.textContent = `${pct}%`;
num.style.cssText = "font-variant-numeric:tabular-nums;font-size:11px;opacity:0.7;min-width:32px;text-align:right;";
wrap.append(track, num);
return wrap;
}
// The cell value is unused; the button acts on the whole row and re-renders.
function actionRenderer(_data: unknown, dataCtx: ValueCellDataContext): HTMLElement {
const button = document.createElement("button");
button.type = "button";
button.textContent = "Advance";
button.style.cssText = fieldStyle + "cursor:pointer;padding:0 10px;background:color-mix(in srgb, currentColor 8%, transparent);";
button.addEventListener("click", () => {
const row = rows[dataCtx.rowIndex];
row.progress = Math.min(100, row.progress + 15);
if (row.progress >= 100) row.status = "Done";
else if (row.status === "Todo") row.status = "In progress";
render();
});
return button;
}
}
/* ===================================== utils ===================================== */
const fieldStyle =
"font:inherit;font-size:12px;line-height:1;height:26px;box-sizing:border-box;color:inherit;" +
"border-radius:6px;border:1px solid color-mix(in srgb, currentColor 22%, transparent);" +
"background:transparent;padding:0 6px;width:100%;";
function trendSvg(data: number[], type: ChartType, color: string): string {
if (data.length === 0) return "";
const w = 120;
const h = 32;
const pad = 3;
const x = scaleLinear().domain([0, data.length - 1]).range([pad, w - pad]);
const min = Math.min(...data);
const max = Math.max(...data);
const range = max - min || 1;
const y = scaleLinear().domain([min - range * 0.15, max + range * 0.15]).range([h - pad, pad]);
if (type === "bar") {
const bandWidth = ((w - pad * 2) / data.length) * 0.7;
const bars = data
.map((value, i) => {
const bx = x(i) - bandWidth / 2;
const by = y(value);
const bh = Math.max(1, h - pad - by);
return `<rect x="${bx.toFixed(1)}" y="${by.toFixed(1)}" width="${bandWidth.toFixed(1)}" height="${bh.toFixed(1)}" rx="1" fill="${color}" />`;
})
.join("");
return svgWrap(w, h, bars);
}
const lineGen = line<number>().x((_, i) => x(i)).y((value) => y(value)).curve(curveCatmullRom);
if (type === "area") {
const areaGen = area<number>().x((_, i) => x(i)).y0(h - pad).y1((value) => y(value)).curve(curveCatmullRom);
return svgWrap(
w,
h,
`<path d="${areaGen(data) ?? ""}" fill="${color}" fill-opacity="0.16" />` +
`<path d="${lineGen(data) ?? ""}" fill="none" stroke="${color}" stroke-width="1.5" />`,
);
}
return svgWrap(w, h, `<path d="${lineGen(data) ?? ""}" fill="none" stroke="${color}" stroke-width="1.5" />`);
}
function svgWrap(w: number, h: number, inner: string): string {
return `<svg width="${w}" height="${h}" viewBox="0 0 ${w} ${h}" style="display:block">${inner}</svg>`;
}
const TASKS = [
"Onboarding flow", "Billing webhook", "Search reindex", "Dark mode", "CSV export",
"Rate limiter", "Audit log", "SSO login", "Image CDN", "Push notifications",
"Draft autosave", "Team invites", "API pagination", "Webhooks v2", "Usage metering", "Locale switch",
];
const COLORS = ["#2563eb", "#16a34a", "#db2777", "#f59e0b", "#7c3aed", "#0891b2", "#dc2626", "#4f46e5"];
// Deterministic so the demo renders identically on every load.
function generateRows(): Row[] {
const rand = mulberry32(0x2f6a13);
return TASKS.map((name, i) => ({
name,
trend: Array.from({ length: 16 }, () => Math.round(20 + rand() * 80)),
status: STATUSES[Math.floor(rand() * STATUSES.length)],
color: COLORS[i % COLORS.length],
due: `2026-${String(1 + Math.floor(rand() * 12)).padStart(2, "0")}-${String(1 + Math.floor(rand() * 28)).padStart(2, "0")}`,
progress: Math.round(rand() * 100),
}));
}
function mulberry32(seed: number): () => number {
let a = seed;
return () => {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}Swap renderers live
Because a renderer is just a function read at draw time, its behavior can depend on
external state. The sparkline reads a chartType variable from scope, so changing
it and calling draw() repaints every visible chart with no data change, no new
viewmodel, and no query.
let chartType = "line";
const trendRenderer: CellRenderer<number[]> = (series, dataCtx) =>
trendSvg(series, chartType, rows[dataCtx.rowIndex].color); // reads chartType live
chartPicker.addEventListener("change", () => {
chartType = chartPicker.value; // "line" | "bar" | "area"
grid.draw(); // repaints the visible cells against the new type
});Try it above: switch the Trend chart control between line, bar, and area. Only the charts change; the rest of the grid is untouched.
The three renderer types
The demo overrides data cells, but every cell in the grid is drawn by a renderer, and there are three types, one for each kind of cell:
- Data cell renderer (
CellRenderer) - the value inside a data cell, the main grid body. This is what the demo above uses. - Facet cell renderer (
FacetCellRenderer) - a row or column header cell, for example the "Q1" or "Revenue" labels. - Facet header renderer (
FacetHeaderRenderer) - the level label that names a facet track, for example "Region" or "Quarter".
All three share the same ownership convention: return content and the layout places it
in the DOM, or return void to signal that you have taken the container over yourself
(the path frameworks like React use).
Data cell renderers
A CellRenderer receives the cell's value and the context objects that say who and
where the cell is:
type CellRenderer = (data: T, dataCtx: ValueCellDataContext, ctx: RendererContext) => string | HTMLElement | HTMLElement[] | void;
interface ValueCellDataContext {
viewModel: GridDataViewModel;
rowIndex: number;
colIndex: number;
rawValue: any;
}
interface RendererContext {
container: HTMLElement;
key: string;
}Return values
| Return type | Behavior |
|---|---|
string | Set as the cell's innerHTML. Use for simple text or HTML strings. |
HTMLElement | Appended as the cell's child. |
HTMLElement[] | All elements appended as children. |
void (undefined) | The layout does not touch the container. The renderer owns it, the path used when a framework like React calls createRoot on the container. |
Two rules follow from that table and from how the grid draws:
- Stay stateless. The grid may call a renderer many times for the same logical
cell, so it has to produce its whole output from
dataanddataCtxevery time and never accumulate state on the side. In the demo, every edit is written back to the row and the grid redraws from the data, so the cell is always a function of the data. - Mind who owns the DOM. What you return decides who manages the lifecycle of the
nodes. Return a string or an element and the grid core owns them: it places, reuses,
and clears them for you across draws. Return nothing (
void) and the grid leaves the cell untouched, handing the lifecycle to your glue code.
Assigning renderers
Data cell renderers are assigned per column via VTrackDef.renderer in the viewmodel
options. Columns without a renderer get the default textRenderer.
const options: GridDataViewModelOptions = {
vTrackDefs: [
undefined, // column 0: default textRenderer
{ renderer: myBarRenderer }, // column 1: custom renderer
{ renderer: sparklineRenderer, cellHeight: 40 }, // column 2: custom with fixed height
],
};
const viewModel = new FlattenedDataViewModel({ ...params, options });Cell height and sample data
The layout measures row height before the first render by calling each column's renderer with a sample value, by default the first row's value. Two options control this for renderers whose height does not follow from the first row:
cellHeight- skip measurement entirely and use this fixed pixel height. Useful for fixed-size content like charts (the demo pins its row height this way).sampleData- provide a representative value for measurement instead of the first row.
const chartDef: VTrackDef<number[]> = {
renderer: createChartRenderer({ chartType: "line", minHeight: 30 }),
cellHeight: 30,
sampleData: [1, 2, 3, 4, 5],
};Facet cell renderers
A FacetCellRenderer draws a single facet cell, the value labels in row and column
headers:
type FacetCellRenderer = (
data: T,
dataCtx: FacetDataContext,
ctx: FacetRendererContext
) => FacetCellContent | El | void;
interface FacetDataContext {
viewModel: GridDataViewModel;
path: (string | null)[];
level: number;
index: number;
flatMeta?: FlatRowMeta;
key: string;
}
interface FlatRowMeta {
depth: number;
isLeaf: boolean;
isExpanded: boolean;
}
interface FacetRendererContext {
render: (viewModel: GridDataViewModel) => void;
cell: HTMLElement;
container: HTMLElement;
}
interface FacetCellContent {
left?: El;
content: El;
right?: El;
}
type El = HTMLElement | HTMLElement[] | string;pathgives the full facet context. For a column facet cell at level 1 showing "Revenue" under "Q1", the path is["Q1", "Revenue"].flatMetais present only for row facets rendered byGroupedRowLayout; it carriesdepth,isLeaf, andisExpandedfor the row.ctx.render(viewModel)triggers a re-render, for interactive facet cells such as a group row you click to expand or collapse.- Instead of a plain string or element, a facet renderer can return a
FacetCellContentobject with up to three slots:left(e.g. an expand icon),content(the label), andright(e.g. a sort indicator).
const groupFacetRenderer: FacetCellRenderer = (data, dataCtx, ctx) => {
const icon = document.createElement("span");
icon.textContent = dataCtx.flatMeta?.isExpanded ? "▼" : "▶";
icon.style.cursor = "pointer";
icon.onclick = () => {
toggleGroup(dataCtx.index); // toggle expand/collapse
ctx.render(updatedViewModel); // and re-render
};
return { left: icon, content: data ?? "" };
};Facet renderers are set via FacetDef.trackRenderer in the viewmodel options:
const options: GridDataViewModelOptions = {
facetDefs: {
row: [{ text: "Region", trackRenderer: groupFacetRenderer }],
col: [{ text: "Quarter", trackRenderer: defaultFacetRenderer }],
axis: "col",
},
};Facet header renderers
A FacetHeaderRenderer draws the level label, the cell that names a facet track such
as "Region" or "Quarter":
type FacetHeaderRenderer = (
text: string,
ctx: HeaderCellContext
) => FacetCellContent | string | HTMLElement | HTMLElement[] | void;
interface HeaderCellContext {
viewModel: GridDataViewModel;
level: number;
key: string;
cell: HTMLElement;
container: HTMLElement;
render: (viewModel: GridDataViewModel) => void;
}
interface FacetCellContent {
left?: El;
content: El;
right?: El;
}
type El = HTMLElement | HTMLElement[] | string;The text argument is the FacetDef.text value. Use ctx.render for interactive
headers, for example a column header you click to sort:
const sortableHeaderRenderer: FacetHeaderRenderer = (text, ctx) => {
const label = document.createElement("span");
label.textContent = text;
label.style.cursor = "pointer";
label.onclick = () => {
applySort(text); // apply sort
ctx.render(updatedViewModel); // and re-render
};
return label;
};Built-in renderers
The grid ships a few renderers so common cells need no custom code:
textRenderer- the default data cell renderer. Displays the value as a single-line string with ellipsis overflow. Columns without aVTrackDef.rendereruse it.createChartRenderer- a factory that returns a data cell renderer for inline SVG"line"or"bar"charts from anumber[]cell value. The demo hand-rolls its own charts with d3 for full control, but this covers the common case.defaultFacetRendereranddefaultFacetHeaderRenderer- the default facet and header renderers; both return the value as a string, withnullbecoming"".
import { createChartRenderer } from "grid/dist/renderer";
const sparkline = createChartRenderer({ chartType: "line", color: "#1976d2" });
const barChart = createChartRenderer({ chartType: "bar", minWidth: 100, minHeight: 24 });
const options: GridDataViewModelOptions = {
vTrackDefs: [
undefined,
{ renderer: sparkline, cellHeight: 20, sampleData: [1, 2, 3] },
],
};Framework rendering (void return)
When a renderer returns void, the layout skips placing content in the DOM: it assumes
the renderer owns the container. This is the pattern for React, Vue, or any framework
that manages its own DOM tree.
The one constraint: the layout measures cell dimensions with getBoundingClientRect
immediately after calling renderers, for column auto-sizing. Framework renders that are
async (like React's default batching) must be flushed synchronously before measurement.
The React bindings in packages/frameworks handle this for you: the adapter creates a
createRoot bound to the cell's container, returns void, and flushes with flushSync
before measurement, so you pass a React component and it is wrapped as a renderer.
import type { FC } from "react";
import type { CellProps } from "frameworks";
const PercentCell: FC<CellProps<number>> = ({ value }) => (
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<div style={{ width: `${value}%`, height: 4, background: value >= 50 ? "#4caf50" : "#f44336" }} />
<span>{value}%</span>
</div>
);
// In a useFlatGrid / usePivotGrid columns config:
const columns = [{ renderer: PercentCell }];Without React, follow the same shape: on the first call mount your root on
ctx.container, on later calls (same ctx.key, cell reused) update it, return void,
and clean up via the onCellRelease hook on the Grid constructor.
const grid = new Grid(config, mountEl, "flat", {
onCellRelease: (key, cell) => myFramework.unmount(cell),
});Conditional renderers via selection rules
VTrackDef.renderer and FacetDef.trackRenderer apply to a whole column or facet
level. To override the renderer for specific cells by their facet context or value, use
grid.selectAll. It matches facet cells by dimension name and value; chain .prop to
override the renderer for the matching cells only, without rebuilding the viewmodel.
// A bar renderer only for Revenue data cells.
grid
.selectAll((dim, dimVal) => dim === "Measure" && dimVal === "Revenue")
.prop({ cellRenderer: barRenderer });
// Target data cells by value: red text for negative revenue.
grid
.selectAll((dim, dimVal) => dim === "Measure" && dimVal === "Revenue")
.selectAllCell((value) => value < 0)
.prop({ cellRenderer: negativeValueRenderer });That is how a renderer can change per column, per row, or per cell after construction. See Column selection for the targeting in action.
Renderer selection priority
For each cell, the layout resolves which renderer to use in this order:
- Selection rule override - a matching
selectAllrule that supplies acellRenderer(data cells) ortrackRenderer(facet cells) wins. When several rules match, the last one inserted wins. - VTrackDef / FacetDef renderer - the renderer set on the column or facet level.
- Default renderer -
textRendererfor data cells,defaultFacetRendererfor facet cells,defaultFacetHeaderRendererfor facet headers.